llm-mock-server 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/desloppify/SKILL.md +308 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000801.json +242 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000905.json +248 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000917.json +248 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000950.json +311 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/claude_launch_prompt.md +17 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/review_result.json +255 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/review_result.template.json +22 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/reviewer_instructions.md +20 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/session.json +20 -0
- package/.desloppify/query.json +284 -0
- package/.desloppify/review_packet_blind.json +1303 -0
- package/.desloppify/review_packets/holistic_packet_20260315_000339.json +1471 -0
- package/.desloppify/state-typescript.json +5114 -0
- package/.desloppify/state-typescript.json.bak +5108 -0
- package/dist/cli-validators.d.ts +7 -0
- package/dist/cli-validators.d.ts.map +1 -0
- package/dist/cli-validators.js +51 -0
- package/dist/cli-validators.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +106 -0
- package/dist/cli.js.map +1 -0
- package/dist/formats/anthropic/index.d.ts +3 -0
- package/dist/formats/anthropic/index.d.ts.map +1 -0
- package/dist/formats/anthropic/index.js +13 -0
- package/dist/formats/anthropic/index.js.map +1 -0
- package/dist/formats/anthropic/parse.d.ts +4 -0
- package/dist/formats/anthropic/parse.d.ts.map +1 -0
- package/dist/formats/anthropic/parse.js +47 -0
- package/dist/formats/anthropic/parse.js.map +1 -0
- package/dist/formats/anthropic/schema.d.ts +75 -0
- package/dist/formats/anthropic/schema.d.ts.map +1 -0
- package/dist/formats/anthropic/schema.js +50 -0
- package/dist/formats/anthropic/schema.js.map +1 -0
- package/dist/formats/anthropic/serialize.d.ts +10 -0
- package/dist/formats/anthropic/serialize.d.ts.map +1 -0
- package/dist/formats/anthropic/serialize.js +73 -0
- package/dist/formats/anthropic/serialize.js.map +1 -0
- package/dist/formats/openai/index.d.ts +3 -0
- package/dist/formats/openai/index.d.ts.map +1 -0
- package/dist/formats/openai/index.js +13 -0
- package/dist/formats/openai/index.js.map +1 -0
- package/dist/formats/openai/parse.d.ts +4 -0
- package/dist/formats/openai/parse.d.ts.map +1 -0
- package/dist/formats/openai/parse.js +33 -0
- package/dist/formats/openai/parse.js.map +1 -0
- package/dist/formats/openai/schema.d.ts +93 -0
- package/dist/formats/openai/schema.d.ts.map +1 -0
- package/dist/formats/openai/schema.js +68 -0
- package/dist/formats/openai/schema.js.map +1 -0
- package/dist/formats/openai/serialize.d.ts +10 -0
- package/dist/formats/openai/serialize.d.ts.map +1 -0
- package/dist/formats/openai/serialize.js +70 -0
- package/dist/formats/openai/serialize.js.map +1 -0
- package/dist/formats/parse-helpers.d.ts +24 -0
- package/dist/formats/parse-helpers.d.ts.map +1 -0
- package/dist/formats/parse-helpers.js +52 -0
- package/dist/formats/parse-helpers.js.map +1 -0
- package/dist/formats/request-helpers.d.ts +13 -0
- package/dist/formats/request-helpers.d.ts.map +1 -0
- package/dist/formats/request-helpers.js +28 -0
- package/dist/formats/request-helpers.js.map +1 -0
- package/dist/formats/responses/index.d.ts +3 -0
- package/dist/formats/responses/index.d.ts.map +1 -0
- package/dist/formats/responses/index.js +13 -0
- package/dist/formats/responses/index.js.map +1 -0
- package/dist/formats/responses/parse.d.ts +4 -0
- package/dist/formats/responses/parse.d.ts.map +1 -0
- package/dist/formats/responses/parse.js +51 -0
- package/dist/formats/responses/parse.js.map +1 -0
- package/dist/formats/responses/schema.d.ts +103 -0
- package/dist/formats/responses/schema.d.ts.map +1 -0
- package/dist/formats/responses/schema.js +61 -0
- package/dist/formats/responses/schema.js.map +1 -0
- package/dist/formats/responses/serialize.d.ts +10 -0
- package/dist/formats/responses/serialize.d.ts.map +1 -0
- package/dist/formats/responses/serialize.js +108 -0
- package/dist/formats/responses/serialize.js.map +1 -0
- package/dist/formats/serialize-helpers.d.ts +14 -0
- package/dist/formats/serialize-helpers.d.ts.map +1 -0
- package/dist/formats/serialize-helpers.js +25 -0
- package/dist/formats/serialize-helpers.js.map +1 -0
- package/dist/formats/types.d.ts +20 -0
- package/dist/formats/types.d.ts.map +1 -0
- package/dist/formats/types.js +2 -0
- package/dist/formats/types.js.map +1 -0
- package/dist/history.d.ts +38 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +48 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +9 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +169 -0
- package/dist/loader.js.map +1 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +42 -0
- package/dist/logger.js.map +1 -0
- package/dist/mock-server.d.ts +102 -0
- package/dist/mock-server.d.ts.map +1 -0
- package/dist/mock-server.js +195 -0
- package/dist/mock-server.js.map +1 -0
- package/dist/route-handler.d.ts +16 -0
- package/dist/route-handler.d.ts.map +1 -0
- package/dist/route-handler.js +75 -0
- package/dist/route-handler.js.map +1 -0
- package/dist/rule-engine.d.ts +24 -0
- package/dist/rule-engine.d.ts.map +1 -0
- package/dist/rule-engine.js +129 -0
- package/dist/rule-engine.js.map +1 -0
- package/dist/sse-writer.d.ts +5 -0
- package/dist/sse-writer.d.ts.map +1 -0
- package/dist/sse-writer.js +23 -0
- package/dist/sse-writer.js.map +1 -0
- package/{src/types/index.ts → dist/types/index.d.ts} +1 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/reply.d.ts +45 -0
- package/dist/types/reply.d.ts.map +1 -0
- package/dist/types/reply.js +2 -0
- package/dist/types/reply.js.map +1 -0
- package/dist/types/request.d.ts +39 -0
- package/dist/types/request.d.ts.map +1 -0
- package/dist/types/request.js +2 -0
- package/dist/types/request.js.map +1 -0
- package/dist/types/rule.d.ts +57 -0
- package/dist/types/rule.d.ts.map +1 -0
- package/dist/types/rule.js +2 -0
- package/dist/types/rule.js.map +1 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +2 -1
- package/scorecard.png +0 -0
- package/src/cli-validators.ts +3 -0
- package/src/cli.ts +6 -2
- package/src/formats/anthropic/index.ts +1 -1
- package/src/formats/anthropic/parse.ts +4 -4
- package/src/formats/anthropic/schema.ts +1 -68
- package/src/formats/anthropic/serialize.ts +9 -5
- package/src/formats/openai/index.ts +1 -1
- package/src/formats/openai/parse.ts +1 -1
- package/src/formats/openai/schema.ts +1 -69
- package/src/formats/openai/serialize.ts +15 -17
- package/src/formats/{parse-helpers.ts → request-helpers.ts} +2 -31
- package/src/formats/responses/index.ts +1 -1
- package/src/formats/responses/parse.ts +1 -1
- package/src/formats/responses/schema.ts +1 -72
- package/src/formats/responses/serialize.ts +9 -5
- package/src/formats/serialize-helpers.ts +30 -0
- package/src/formats/types.ts +3 -3
- package/src/loader.ts +7 -11
- package/src/logger.ts +19 -25
- package/src/mock-server.ts +10 -14
- package/src/route-handler.ts +1 -1
- package/src/rule-engine.ts +23 -1
- package/src/types/reply.ts +6 -10
- package/src/types/request.ts +7 -11
- package/src/types/rule.ts +3 -10
- package/src/types.ts +3 -5
- package/test/formats/anthropic.test.ts +4 -4
- package/test/formats/parse-helpers.test.ts +275 -0
- package/test/formats/responses.test.ts +4 -4
- package/test/helpers/make-req.ts +18 -0
- package/test/history.test.ts +348 -0
- package/test/loader.test.ts +11 -27
- package/test/logger.test.ts +294 -0
- package/test/mock-server.test.ts +1 -1
- package/test/rule-engine.test.ts +8 -22
- package/test/formats/anthropic-schema.test.ts +0 -192
- package/test/formats/openai-schema.test.ts +0 -105
- package/test/formats/responses-schema.test.ts +0 -114
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
splitText,
|
|
4
|
+
genId,
|
|
5
|
+
toolId,
|
|
6
|
+
shouldEmitText,
|
|
7
|
+
finishReason,
|
|
8
|
+
MS_PER_SECOND,
|
|
9
|
+
DEFAULT_USAGE,
|
|
10
|
+
} from "../../src/formats/serialize-helpers.js";
|
|
11
|
+
import {
|
|
12
|
+
isStreaming,
|
|
13
|
+
buildMockRequest,
|
|
14
|
+
} from "../../src/formats/request-helpers.js";
|
|
15
|
+
import type { ReplyObject } from "../../src/types.js";
|
|
16
|
+
|
|
17
|
+
describe("parse-helpers", () => {
|
|
18
|
+
describe("constants", () => {
|
|
19
|
+
it("MS_PER_SECOND is 1000", () => {
|
|
20
|
+
expect(MS_PER_SECOND).toBe(1000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("DEFAULT_USAGE has expected shape", () => {
|
|
24
|
+
expect(DEFAULT_USAGE).toEqual({ input: 10, output: 5 });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("splitText", () => {
|
|
29
|
+
it("returns the full string when chunkSize is 0", () => {
|
|
30
|
+
expect(splitText("hello", 0)).toEqual(["hello"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the full string when chunkSize is negative", () => {
|
|
34
|
+
expect(splitText("hello", -1)).toEqual(["hello"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns the full string when text fits in one chunk", () => {
|
|
38
|
+
expect(splitText("hello", 10)).toEqual(["hello"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns the full string when chunkSize equals text length", () => {
|
|
42
|
+
expect(splitText("hello", 5)).toEqual(["hello"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("splits text into equal chunks", () => {
|
|
46
|
+
expect(splitText("abcdef", 2)).toEqual(["ab", "cd", "ef"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles a remainder chunk", () => {
|
|
50
|
+
expect(splitText("abcde", 2)).toEqual(["ab", "cd", "e"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("splits into single characters with chunkSize 1", () => {
|
|
54
|
+
expect(splitText("abc", 1)).toEqual(["a", "b", "c"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns single-element array for empty string", () => {
|
|
58
|
+
expect(splitText("", 5)).toEqual([""]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("genId", () => {
|
|
63
|
+
it("starts with the given prefix", () => {
|
|
64
|
+
const id = genId("chatcmpl");
|
|
65
|
+
expect(id).toMatch(/^chatcmpl_/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("generates unique ids", () => {
|
|
69
|
+
const a = genId("msg");
|
|
70
|
+
const b = genId("msg");
|
|
71
|
+
// Could collide if Date.now() returns the same ms, but format is still valid
|
|
72
|
+
expect(a).toMatch(/^msg_[a-z0-9]+$/);
|
|
73
|
+
expect(b).toMatch(/^msg_[a-z0-9]+$/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("toolId", () => {
|
|
78
|
+
it("uses the tool's own id when present", () => {
|
|
79
|
+
expect(toolId({ id: "call_abc" }, "call", 0)).toBe("call_abc");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("generates an id when tool has no id", () => {
|
|
83
|
+
const id = toolId({}, "call", 2);
|
|
84
|
+
expect(id).toMatch(/^call_[a-z0-9]+_2$/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("generates an id when tool id is undefined", () => {
|
|
88
|
+
const id = toolId({ id: undefined }, "call", 0);
|
|
89
|
+
expect(id).toMatch(/^call_[a-z0-9]+_0$/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("shouldEmitText", () => {
|
|
94
|
+
it("returns true when reply has text", () => {
|
|
95
|
+
expect(shouldEmitText({ text: "hello" })).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns true when reply has no text, no tools, no reasoning", () => {
|
|
99
|
+
expect(shouldEmitText({})).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns false when reply has only tools", () => {
|
|
103
|
+
expect(shouldEmitText({ tools: [{ name: "fn", args: {} }] })).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns false when reply has only reasoning", () => {
|
|
107
|
+
expect(shouldEmitText({ reasoning: "thinking..." })).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns true when reply has text and tools", () => {
|
|
111
|
+
expect(shouldEmitText({ text: "hi", tools: [{ name: "fn", args: {} }] })).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns true for empty text with no tools or reasoning", () => {
|
|
115
|
+
expect(shouldEmitText({ text: "" })).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("finishReason", () => {
|
|
120
|
+
it("returns onTools when tools are present", () => {
|
|
121
|
+
const reply: ReplyObject = { tools: [{ name: "fn", args: {} }] };
|
|
122
|
+
expect(finishReason(reply, "tool_calls", "stop")).toBe("tool_calls");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns onStop when no tools", () => {
|
|
126
|
+
expect(finishReason({ text: "hi" }, "tool_calls", "stop")).toBe("stop");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns onStop when tools array is empty", () => {
|
|
130
|
+
expect(finishReason({ tools: [] }, "tool_calls", "stop")).toBe("stop");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns onStop when tools is undefined", () => {
|
|
134
|
+
expect(finishReason({}, "tool_calls", "stop")).toBe("stop");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("isStreaming", () => {
|
|
139
|
+
it("returns true when stream is true", () => {
|
|
140
|
+
expect(isStreaming({ stream: true })).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns false when stream is false", () => {
|
|
144
|
+
expect(isStreaming({ stream: false })).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns true when stream is absent", () => {
|
|
148
|
+
expect(isStreaming({})).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns true for null body", () => {
|
|
152
|
+
expect(isStreaming(null)).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns true for non-object body", () => {
|
|
156
|
+
expect(isStreaming("not an object")).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns true for undefined body", () => {
|
|
160
|
+
expect(isStreaming(undefined)).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("buildMockRequest", () => {
|
|
165
|
+
it("builds a minimal request with defaults", () => {
|
|
166
|
+
const result = buildMockRequest(
|
|
167
|
+
"openai",
|
|
168
|
+
{},
|
|
169
|
+
[{ role: "user", content: "hello" }],
|
|
170
|
+
undefined,
|
|
171
|
+
"gpt-4",
|
|
172
|
+
{ messages: [] },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(result.format).toBe("openai");
|
|
176
|
+
expect(result.model).toBe("gpt-4");
|
|
177
|
+
expect(result.streaming).toBe(true);
|
|
178
|
+
expect(result.lastMessage).toBe("hello");
|
|
179
|
+
expect(result.systemMessage).toBe("");
|
|
180
|
+
expect(result.tools).toBeUndefined();
|
|
181
|
+
expect(result.toolNames).toEqual([]);
|
|
182
|
+
expect(result.lastToolCallId).toBeUndefined();
|
|
183
|
+
expect(result.headers).toEqual({});
|
|
184
|
+
expect(result.path).toBe("");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("uses model from body when provided", () => {
|
|
188
|
+
const result = buildMockRequest(
|
|
189
|
+
"anthropic",
|
|
190
|
+
{ model: "claude-sonnet" },
|
|
191
|
+
[],
|
|
192
|
+
undefined,
|
|
193
|
+
"default-model",
|
|
194
|
+
{},
|
|
195
|
+
);
|
|
196
|
+
expect(result.model).toBe("claude-sonnet");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("falls back to default model when body model is empty string", () => {
|
|
200
|
+
const result = buildMockRequest(
|
|
201
|
+
"openai",
|
|
202
|
+
{ model: "" },
|
|
203
|
+
[],
|
|
204
|
+
undefined,
|
|
205
|
+
"gpt-4",
|
|
206
|
+
{},
|
|
207
|
+
);
|
|
208
|
+
expect(result.model).toBe("gpt-4");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("extracts last user message", () => {
|
|
212
|
+
const messages = [
|
|
213
|
+
{ role: "user" as const, content: "first" },
|
|
214
|
+
{ role: "assistant" as const, content: "reply" },
|
|
215
|
+
{ role: "user" as const, content: "second" },
|
|
216
|
+
];
|
|
217
|
+
const result = buildMockRequest("openai", {}, messages, undefined, "m", {});
|
|
218
|
+
expect(result.lastMessage).toBe("second");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("extracts system message", () => {
|
|
222
|
+
const messages = [
|
|
223
|
+
{ role: "system" as const, content: "be helpful" },
|
|
224
|
+
{ role: "user" as const, content: "hi" },
|
|
225
|
+
];
|
|
226
|
+
const result = buildMockRequest("openai", {}, messages, undefined, "m", {});
|
|
227
|
+
expect(result.systemMessage).toBe("be helpful");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("extracts tool names", () => {
|
|
231
|
+
const tools = [
|
|
232
|
+
{ name: "get_weather", parameters: {} },
|
|
233
|
+
{ name: "search", parameters: {} },
|
|
234
|
+
];
|
|
235
|
+
const result = buildMockRequest("openai", {}, [], tools, "m", {});
|
|
236
|
+
expect(result.toolNames).toEqual(["get_weather", "search"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("extracts last tool call id", () => {
|
|
240
|
+
const messages = [
|
|
241
|
+
{ role: "tool" as const, content: "result1", toolCallId: "call_1" },
|
|
242
|
+
{ role: "tool" as const, content: "result2", toolCallId: "call_2" },
|
|
243
|
+
];
|
|
244
|
+
const result = buildMockRequest("openai", {}, messages, undefined, "m", {});
|
|
245
|
+
expect(result.lastToolCallId).toBe("call_2");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("sets streaming to false when stream is false", () => {
|
|
249
|
+
const result = buildMockRequest("openai", { stream: false }, [], undefined, "m", {});
|
|
250
|
+
expect(result.streaming).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("uses provided meta for headers and path", () => {
|
|
254
|
+
const meta = {
|
|
255
|
+
headers: { authorization: "Bearer sk-test" },
|
|
256
|
+
path: "/v1/chat/completions",
|
|
257
|
+
};
|
|
258
|
+
const result = buildMockRequest("openai", {}, [], undefined, "m", {}, meta);
|
|
259
|
+
expect(result.headers).toEqual({ authorization: "Bearer sk-test" });
|
|
260
|
+
expect(result.path).toBe("/v1/chat/completions");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns empty lastMessage when no user messages", () => {
|
|
264
|
+
const result = buildMockRequest(
|
|
265
|
+
"openai",
|
|
266
|
+
{},
|
|
267
|
+
[{ role: "system" as const, content: "sys" }],
|
|
268
|
+
undefined,
|
|
269
|
+
"m",
|
|
270
|
+
{},
|
|
271
|
+
);
|
|
272
|
+
expect(result.lastMessage).toBe("");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -275,10 +275,10 @@ describe("Responses Format", () => {
|
|
|
275
275
|
"codex-mini",
|
|
276
276
|
) as ResponsesComplete;
|
|
277
277
|
const fnCall = result.output.find((o) => o.type === "function_call");
|
|
278
|
-
|
|
279
|
-
expect(fnCall
|
|
280
|
-
expect(fnCall
|
|
281
|
-
expect(fnCall
|
|
278
|
+
if (!fnCall) throw new Error("expected function_call output");
|
|
279
|
+
expect(fnCall.name).toBe("read_file");
|
|
280
|
+
expect(fnCall.status).toBe("completed");
|
|
281
|
+
expect(fnCall.call_id).toBeTypeOf("string");
|
|
282
282
|
});
|
|
283
283
|
|
|
284
284
|
it("includes usage tokens", () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MockRequest } from "../../src/types.js";
|
|
2
|
+
|
|
3
|
+
export function makeReq(overrides: Partial<MockRequest> = {}): MockRequest {
|
|
4
|
+
return {
|
|
5
|
+
format: "openai",
|
|
6
|
+
model: "gpt-5.4",
|
|
7
|
+
streaming: true,
|
|
8
|
+
messages: [{ role: "user", content: "hello" }],
|
|
9
|
+
lastMessage: "hello",
|
|
10
|
+
systemMessage: "",
|
|
11
|
+
toolNames: [],
|
|
12
|
+
lastToolCallId: undefined,
|
|
13
|
+
raw: {},
|
|
14
|
+
headers: {},
|
|
15
|
+
path: "/v1/chat/completions",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { RequestHistory, type RecordedRequest } from "../src/history.js";
|
|
3
|
+
import { makeReq } from "./helpers/make-req.js";
|
|
4
|
+
|
|
5
|
+
describe("RequestHistory", () => {
|
|
6
|
+
let history: RequestHistory;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
history = new RequestHistory();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("record()", () => {
|
|
13
|
+
it("adds an entry", () => {
|
|
14
|
+
history.record(makeReq(), "rule-1");
|
|
15
|
+
expect(history.count()).toBe(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("adds multiple entries in order", () => {
|
|
19
|
+
history.record(makeReq({ lastMessage: "first" }), "r1");
|
|
20
|
+
history.record(makeReq({ lastMessage: "second" }), "r2");
|
|
21
|
+
history.record(makeReq({ lastMessage: "third" }), undefined);
|
|
22
|
+
|
|
23
|
+
expect(history.count()).toBe(3);
|
|
24
|
+
expect(history.first()?.request.lastMessage).toBe("first");
|
|
25
|
+
expect(history.last()?.request.lastMessage).toBe("third");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stores the matched rule name", () => {
|
|
29
|
+
history.record(makeReq(), "my-rule");
|
|
30
|
+
expect(history.first()?.rule).toBe("my-rule");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("stores undefined rule when fallback was used", () => {
|
|
34
|
+
history.record(makeReq(), undefined);
|
|
35
|
+
expect(history.first()?.rule).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("sets a numeric timestamp", () => {
|
|
39
|
+
const before = Date.now();
|
|
40
|
+
history.record(makeReq(), "r");
|
|
41
|
+
const after = Date.now();
|
|
42
|
+
|
|
43
|
+
const ts = history.first()!.timestamp;
|
|
44
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
45
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("count()", () => {
|
|
50
|
+
it("returns 0 for empty history", () => {
|
|
51
|
+
expect(history.count()).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns the correct count after multiple records", () => {
|
|
55
|
+
history.record(makeReq(), "a");
|
|
56
|
+
history.record(makeReq(), "b");
|
|
57
|
+
history.record(makeReq(), "c");
|
|
58
|
+
expect(history.count()).toBe(3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns 0 after clear", () => {
|
|
62
|
+
history.record(makeReq(), "a");
|
|
63
|
+
history.clear();
|
|
64
|
+
expect(history.count()).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("first()", () => {
|
|
69
|
+
it("returns undefined when history is empty", () => {
|
|
70
|
+
expect(history.first()).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns the first recorded entry", () => {
|
|
74
|
+
history.record(makeReq({ lastMessage: "alpha" }), "r1");
|
|
75
|
+
history.record(makeReq({ lastMessage: "beta" }), "r2");
|
|
76
|
+
|
|
77
|
+
const entry = history.first();
|
|
78
|
+
expect(entry).toBeDefined();
|
|
79
|
+
expect(entry!.request.lastMessage).toBe("alpha");
|
|
80
|
+
expect(entry!.rule).toBe("r1");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("last()", () => {
|
|
85
|
+
it("returns undefined when history is empty", () => {
|
|
86
|
+
expect(history.last()).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns the most recent entry", () => {
|
|
90
|
+
history.record(makeReq({ lastMessage: "alpha" }), "r1");
|
|
91
|
+
history.record(makeReq({ lastMessage: "beta" }), "r2");
|
|
92
|
+
|
|
93
|
+
const entry = history.last();
|
|
94
|
+
expect(entry).toBeDefined();
|
|
95
|
+
expect(entry!.request.lastMessage).toBe("beta");
|
|
96
|
+
expect(entry!.rule).toBe("r2");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns the same entry as first() when there is only one", () => {
|
|
100
|
+
history.record(makeReq(), "only");
|
|
101
|
+
expect(history.first()).toBe(history.last());
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("at()", () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
history.record(makeReq({ lastMessage: "zero" }), "r0");
|
|
108
|
+
history.record(makeReq({ lastMessage: "one" }), "r1");
|
|
109
|
+
history.record(makeReq({ lastMessage: "two" }), "r2");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns the entry at a positive index", () => {
|
|
113
|
+
expect(history.at(0)?.request.lastMessage).toBe("zero");
|
|
114
|
+
expect(history.at(1)?.request.lastMessage).toBe("one");
|
|
115
|
+
expect(history.at(2)?.request.lastMessage).toBe("two");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns the entry at a negative index", () => {
|
|
119
|
+
expect(history.at(-1)?.request.lastMessage).toBe("two");
|
|
120
|
+
expect(history.at(-2)?.request.lastMessage).toBe("one");
|
|
121
|
+
expect(history.at(-3)?.request.lastMessage).toBe("zero");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns undefined for out-of-bounds positive index", () => {
|
|
125
|
+
expect(history.at(3)).toBeUndefined();
|
|
126
|
+
expect(history.at(100)).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns undefined for out-of-bounds negative index", () => {
|
|
130
|
+
expect(history.at(-4)).toBeUndefined();
|
|
131
|
+
expect(history.at(-100)).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns undefined when history is empty", () => {
|
|
135
|
+
const empty = new RequestHistory();
|
|
136
|
+
expect(empty.at(0)).toBeUndefined();
|
|
137
|
+
expect(empty.at(-1)).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("where()", () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
history.record(makeReq({ lastMessage: "hello", model: "gpt-5.4" }), "rule-a");
|
|
144
|
+
history.record(makeReq({ lastMessage: "world", model: "claude-4" }), undefined);
|
|
145
|
+
history.record(makeReq({ lastMessage: "hello again", model: "gpt-5.4" }), "rule-b");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("filters entries by predicate", () => {
|
|
149
|
+
const matched = history.where((e) => e.rule !== undefined);
|
|
150
|
+
expect(matched).toHaveLength(2);
|
|
151
|
+
expect(matched[0].rule).toBe("rule-a");
|
|
152
|
+
expect(matched[1].rule).toBe("rule-b");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("filters by request properties", () => {
|
|
156
|
+
const claudeRequests = history.where((e) => e.request.model === "claude-4");
|
|
157
|
+
expect(claudeRequests).toHaveLength(1);
|
|
158
|
+
expect(claudeRequests[0].request.lastMessage).toBe("world");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns an empty array when nothing matches", () => {
|
|
162
|
+
const none = history.where((e) => e.request.lastMessage === "nonexistent");
|
|
163
|
+
expect(none).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns all entries when predicate always returns true", () => {
|
|
167
|
+
const all = history.where(() => true);
|
|
168
|
+
expect(all).toHaveLength(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns an empty array on empty history", () => {
|
|
172
|
+
const empty = new RequestHistory();
|
|
173
|
+
expect(empty.where(() => true)).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("all getter", () => {
|
|
178
|
+
it("returns an empty array when history is empty", () => {
|
|
179
|
+
expect(history.all).toEqual([]);
|
|
180
|
+
expect(history.all).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns all recorded entries in insertion order", () => {
|
|
184
|
+
history.record(makeReq({ lastMessage: "a" }), "r1");
|
|
185
|
+
history.record(makeReq({ lastMessage: "b" }), "r2");
|
|
186
|
+
|
|
187
|
+
const entries = history.all;
|
|
188
|
+
expect(entries).toHaveLength(2);
|
|
189
|
+
expect(entries[0].request.lastMessage).toBe("a");
|
|
190
|
+
expect(entries[1].request.lastMessage).toBe("b");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns a readonly array (same reference as internal entries)", () => {
|
|
194
|
+
history.record(makeReq(), "r");
|
|
195
|
+
const a = history.all;
|
|
196
|
+
const b = history.all;
|
|
197
|
+
expect(a).toBe(b);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("reflects mutations after further records", () => {
|
|
201
|
+
history.record(makeReq({ lastMessage: "before" }), "r");
|
|
202
|
+
const ref = history.all;
|
|
203
|
+
expect(ref).toHaveLength(1);
|
|
204
|
+
|
|
205
|
+
history.record(makeReq({ lastMessage: "after" }), "r2");
|
|
206
|
+
// `all` exposes the internal array, so the earlier reference sees the new entry
|
|
207
|
+
expect(ref).toHaveLength(2);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("clear()", () => {
|
|
212
|
+
it("empties the history", () => {
|
|
213
|
+
history.record(makeReq(), "r1");
|
|
214
|
+
history.record(makeReq(), "r2");
|
|
215
|
+
expect(history.count()).toBe(2);
|
|
216
|
+
|
|
217
|
+
history.clear();
|
|
218
|
+
expect(history.count()).toBe(0);
|
|
219
|
+
expect(history.first()).toBeUndefined();
|
|
220
|
+
expect(history.last()).toBeUndefined();
|
|
221
|
+
expect(history.all).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("is idempotent on empty history", () => {
|
|
225
|
+
history.clear();
|
|
226
|
+
expect(history.count()).toBe(0);
|
|
227
|
+
history.clear();
|
|
228
|
+
expect(history.count()).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("allows recording again after clear", () => {
|
|
232
|
+
history.record(makeReq({ lastMessage: "old" }), "r1");
|
|
233
|
+
history.clear();
|
|
234
|
+
history.record(makeReq({ lastMessage: "new" }), "r2");
|
|
235
|
+
|
|
236
|
+
expect(history.count()).toBe(1);
|
|
237
|
+
expect(history.first()?.request.lastMessage).toBe("new");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("Iterator protocol (for...of)", () => {
|
|
242
|
+
it("iterates over all entries in order", () => {
|
|
243
|
+
history.record(makeReq({ lastMessage: "a" }), "r1");
|
|
244
|
+
history.record(makeReq({ lastMessage: "b" }), "r2");
|
|
245
|
+
history.record(makeReq({ lastMessage: "c" }), "r3");
|
|
246
|
+
|
|
247
|
+
const messages: string[] = [];
|
|
248
|
+
for (const entry of history) {
|
|
249
|
+
messages.push(entry.request.lastMessage);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
expect(messages).toEqual(["a", "b", "c"]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("yields nothing for empty history", () => {
|
|
256
|
+
const messages: string[] = [];
|
|
257
|
+
for (const entry of history) {
|
|
258
|
+
messages.push(entry.request.lastMessage);
|
|
259
|
+
}
|
|
260
|
+
expect(messages).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("works with spread operator", () => {
|
|
264
|
+
history.record(makeReq({ lastMessage: "x" }), "r1");
|
|
265
|
+
history.record(makeReq({ lastMessage: "y" }), "r2");
|
|
266
|
+
|
|
267
|
+
const entries: RecordedRequest[] = [...history];
|
|
268
|
+
expect(entries).toHaveLength(2);
|
|
269
|
+
expect(entries[0].request.lastMessage).toBe("x");
|
|
270
|
+
expect(entries[1].request.lastMessage).toBe("y");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("works with Array.from()", () => {
|
|
274
|
+
history.record(makeReq(), "r1");
|
|
275
|
+
history.record(makeReq(), "r2");
|
|
276
|
+
|
|
277
|
+
const arr = Array.from(history);
|
|
278
|
+
expect(arr).toHaveLength(2);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("supports destructuring", () => {
|
|
282
|
+
history.record(makeReq({ lastMessage: "first" }), "r1");
|
|
283
|
+
history.record(makeReq({ lastMessage: "second" }), "r2");
|
|
284
|
+
history.record(makeReq({ lastMessage: "third" }), "r3");
|
|
285
|
+
|
|
286
|
+
const [first, second, third] = history;
|
|
287
|
+
expect(first.request.lastMessage).toBe("first");
|
|
288
|
+
expect(second.request.lastMessage).toBe("second");
|
|
289
|
+
expect(third.request.lastMessage).toBe("third");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("edge cases", () => {
|
|
294
|
+
it("preserves the full MockRequest object", () => {
|
|
295
|
+
const req = makeReq({
|
|
296
|
+
format: "anthropic",
|
|
297
|
+
model: "claude-4",
|
|
298
|
+
streaming: false,
|
|
299
|
+
lastMessage: "test message",
|
|
300
|
+
systemMessage: "be helpful",
|
|
301
|
+
toolNames: ["search", "calc"],
|
|
302
|
+
lastToolCallId: "call_123",
|
|
303
|
+
path: "/v1/messages",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
history.record(req, "complex-rule");
|
|
307
|
+
const recorded = history.first()!;
|
|
308
|
+
|
|
309
|
+
expect(recorded.request.format).toBe("anthropic");
|
|
310
|
+
expect(recorded.request.model).toBe("claude-4");
|
|
311
|
+
expect(recorded.request.streaming).toBe(false);
|
|
312
|
+
expect(recorded.request.lastMessage).toBe("test message");
|
|
313
|
+
expect(recorded.request.systemMessage).toBe("be helpful");
|
|
314
|
+
expect(recorded.request.toolNames).toEqual(["search", "calc"]);
|
|
315
|
+
expect(recorded.request.lastToolCallId).toBe("call_123");
|
|
316
|
+
expect(recorded.request.path).toBe("/v1/messages");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("handles many entries without issue", () => {
|
|
320
|
+
for (let i = 0; i < 1000; i++) {
|
|
321
|
+
history.record(makeReq({ lastMessage: `msg-${i}` }), `rule-${i}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
expect(history.count()).toBe(1000);
|
|
325
|
+
expect(history.first()?.request.lastMessage).toBe("msg-0");
|
|
326
|
+
expect(history.last()?.request.lastMessage).toBe("msg-999");
|
|
327
|
+
expect(history.at(500)?.request.lastMessage).toBe("msg-500");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("where() does not modify the original entries", () => {
|
|
331
|
+
history.record(makeReq(), "r1");
|
|
332
|
+
history.record(makeReq(), "r2");
|
|
333
|
+
|
|
334
|
+
const filtered = history.where(() => false);
|
|
335
|
+
expect(filtered).toHaveLength(0);
|
|
336
|
+
expect(history.count()).toBe(2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("each entry gets its own timestamp", () => {
|
|
340
|
+
history.record(makeReq(), "r1");
|
|
341
|
+
history.record(makeReq(), "r2");
|
|
342
|
+
|
|
343
|
+
const t1 = history.at(0)!.timestamp;
|
|
344
|
+
const t2 = history.at(1)!.timestamp;
|
|
345
|
+
expect(t2).toBeGreaterThanOrEqual(t1);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|