@flink-app/test-utils 1.0.0 → 2.0.0-alpha.49
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/CHANGELOG.md +12 -0
- package/dist/ai/conversation.d.ts +27 -0
- package/dist/ai/conversation.js +30 -0
- package/dist/ai/index.d.ts +4 -0
- package/dist/ai/index.js +20 -0
- package/dist/ai/mockContext.d.ts +16 -0
- package/dist/ai/mockContext.js +39 -0
- package/dist/ai/mockLLMAdapter.d.ts +90 -0
- package/dist/ai/mockLLMAdapter.js +252 -0
- package/dist/ai/mockTool.d.ts +64 -0
- package/dist/ai/mockTool.js +133 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +9 -5
- package/spec/ai/conversation.spec.ts +280 -0
- package/spec/ai/mockLLMAdapter.spec.ts +533 -0
- package/spec/ai/mockTool.spec.ts +313 -0
- package/spec/support/jasmine.json +7 -0
- package/src/ai/conversation.ts +54 -0
- package/src/ai/index.ts +4 -0
- package/src/ai/mockContext.ts +41 -0
- package/src/ai/mockLLMAdapter.ts +238 -0
- package/src/ai/mockTool.ts +135 -0
- package/src/index.ts +1 -0
- package/tsconfig.dist.json +4 -0
- package/tsconfig.json +6 -5
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mockTool } from "../../src/ai/mockTool";
|
|
3
|
+
import { mockToolContext } from "../../src/ai/mockContext";
|
|
4
|
+
|
|
5
|
+
describe("mockTool", () => {
|
|
6
|
+
describe("canned responses", () => {
|
|
7
|
+
it("should return canned success response", async () => {
|
|
8
|
+
const tool = mockTool({
|
|
9
|
+
name: "get_weather",
|
|
10
|
+
inputSchema: z.object({ city: z.string() }),
|
|
11
|
+
response: { temperature: 22, conditions: "sunny" },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const ctx = mockToolContext();
|
|
15
|
+
const result = await tool.fn({
|
|
16
|
+
input: { city: "Stockholm" },
|
|
17
|
+
ctx,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(result.success).toBe(true);
|
|
21
|
+
if (result.success) {
|
|
22
|
+
expect(result.data).toEqual({ temperature: 22, conditions: "sunny" });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should return canned error response", async () => {
|
|
27
|
+
const tool = mockTool({
|
|
28
|
+
name: "failing_tool",
|
|
29
|
+
inputSchema: z.object({}),
|
|
30
|
+
error: { error: "Tool failed", code: "MOCK_ERROR" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const ctx = mockToolContext();
|
|
34
|
+
const result = await tool.fn({ input: {}, ctx });
|
|
35
|
+
|
|
36
|
+
expect(result.success).toBe(false);
|
|
37
|
+
if (!result.success) {
|
|
38
|
+
expect(result.error).toBe("Tool failed");
|
|
39
|
+
expect(result.code).toBe("MOCK_ERROR");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should return empty object when no response configured", async () => {
|
|
44
|
+
const tool = mockTool({
|
|
45
|
+
name: "empty_tool",
|
|
46
|
+
inputSchema: z.object({}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const ctx = mockToolContext();
|
|
50
|
+
const result = await tool.fn({ input: {}, ctx });
|
|
51
|
+
|
|
52
|
+
expect(result.success).toBe(true);
|
|
53
|
+
if (result.success) {
|
|
54
|
+
expect(result.data).toEqual({});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("custom functions", () => {
|
|
60
|
+
it("should execute custom function", async () => {
|
|
61
|
+
const tool = mockTool({
|
|
62
|
+
name: "calculate",
|
|
63
|
+
inputSchema: z.object({ a: z.number(), b: z.number() }),
|
|
64
|
+
fn: async ({ input }) => ({
|
|
65
|
+
success: true,
|
|
66
|
+
data: { result: input.a + input.b },
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const ctx = mockToolContext();
|
|
71
|
+
const result = await tool.fn({
|
|
72
|
+
input: { a: 5, b: 3 },
|
|
73
|
+
ctx,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.success).toBe(true);
|
|
77
|
+
if (result.success) {
|
|
78
|
+
expect(result.data.result).toBe(8);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should pass context to custom function", async () => {
|
|
83
|
+
const mockRepo = { findById: jasmine.createSpy("findById") };
|
|
84
|
+
const tool = mockTool({
|
|
85
|
+
name: "get_car",
|
|
86
|
+
inputSchema: z.object({ id: z.string() }),
|
|
87
|
+
fn: async ({ input, ctx }) => {
|
|
88
|
+
(ctx.repos as any).carRepo.findById(input.id);
|
|
89
|
+
return { success: true, data: { car: "data" } };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const ctx = mockToolContext({ repos: { carRepo: mockRepo as any } });
|
|
94
|
+
await tool.fn({ input: { id: "123" }, ctx });
|
|
95
|
+
|
|
96
|
+
expect(mockRepo.findById).toHaveBeenCalledWith("123");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should pass user to custom function", async () => {
|
|
100
|
+
const tool = mockTool({
|
|
101
|
+
name: "user_tool",
|
|
102
|
+
inputSchema: z.object({}),
|
|
103
|
+
fn: async ({ user }) => ({
|
|
104
|
+
success: true,
|
|
105
|
+
data: { userId: user?.id },
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const ctx = mockToolContext();
|
|
110
|
+
const result = await tool.fn({
|
|
111
|
+
input: {},
|
|
112
|
+
ctx,
|
|
113
|
+
user: { id: "user-123" },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
if (result.success) {
|
|
118
|
+
expect(result.data.userId).toBe("user-123");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("invocation tracking", () => {
|
|
124
|
+
it("should track invocations", async () => {
|
|
125
|
+
const tool = mockTool({
|
|
126
|
+
name: "test_tool",
|
|
127
|
+
inputSchema: z.object({ value: z.string() }),
|
|
128
|
+
response: { ok: true },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const ctx = mockToolContext();
|
|
132
|
+
await tool.fn({ input: { value: "first" }, ctx });
|
|
133
|
+
await tool.fn({ input: { value: "second" }, ctx });
|
|
134
|
+
await tool.fn({ input: { value: "third" }, ctx });
|
|
135
|
+
|
|
136
|
+
expect(tool.invocations.length).toBe(3);
|
|
137
|
+
expect(tool.invocations[0].input).toEqual({ value: "first" });
|
|
138
|
+
expect(tool.invocations[1].input).toEqual({ value: "second" });
|
|
139
|
+
expect(tool.invocations[2].input).toEqual({ value: "third" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should track user in invocations", async () => {
|
|
143
|
+
const tool = mockTool({
|
|
144
|
+
name: "test_tool",
|
|
145
|
+
inputSchema: z.object({}),
|
|
146
|
+
response: { ok: true },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const ctx = mockToolContext();
|
|
150
|
+
const user = { id: "user-123", permissions: ["admin"] };
|
|
151
|
+
await tool.fn({ input: {}, ctx, user });
|
|
152
|
+
|
|
153
|
+
expect(tool.invocations.length).toBe(1);
|
|
154
|
+
expect(tool.invocations[0].user).toEqual(user);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should get last invocation", async () => {
|
|
158
|
+
const tool = mockTool({
|
|
159
|
+
name: "test_tool",
|
|
160
|
+
inputSchema: z.object({ value: z.string() }),
|
|
161
|
+
response: { ok: true },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const ctx = mockToolContext();
|
|
165
|
+
await tool.fn({ input: { value: "first" }, ctx });
|
|
166
|
+
await tool.fn({ input: { value: "last" }, ctx });
|
|
167
|
+
|
|
168
|
+
const last = tool.getLastInvocation();
|
|
169
|
+
expect(last?.input).toEqual({ value: "last" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should return undefined for last invocation when empty", () => {
|
|
173
|
+
const tool = mockTool({
|
|
174
|
+
name: "test_tool",
|
|
175
|
+
inputSchema: z.object({}),
|
|
176
|
+
response: {},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(tool.getLastInvocation()).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("should reset invocations", async () => {
|
|
183
|
+
const tool = mockTool({
|
|
184
|
+
name: "test_tool",
|
|
185
|
+
inputSchema: z.object({}),
|
|
186
|
+
response: { ok: true },
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const ctx = mockToolContext();
|
|
190
|
+
await tool.fn({ input: {}, ctx });
|
|
191
|
+
await tool.fn({ input: {}, ctx });
|
|
192
|
+
|
|
193
|
+
expect(tool.invocations.length).toBe(2);
|
|
194
|
+
|
|
195
|
+
tool.reset();
|
|
196
|
+
|
|
197
|
+
expect(tool.invocations.length).toBe(0);
|
|
198
|
+
expect(tool.getLastInvocation()).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("tool props", () => {
|
|
203
|
+
it("should create correct tool props", () => {
|
|
204
|
+
const inputSchema = z.object({ city: z.string() });
|
|
205
|
+
const outputSchema = z.object({ temp: z.number() });
|
|
206
|
+
|
|
207
|
+
const tool = mockTool({
|
|
208
|
+
name: "get_weather",
|
|
209
|
+
description: "Get weather for a city",
|
|
210
|
+
inputSchema,
|
|
211
|
+
outputSchema,
|
|
212
|
+
response: { temp: 22 },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(tool.props.id).toBe("get_weather");
|
|
216
|
+
expect(tool.props.description).toBe("Get weather for a city");
|
|
217
|
+
expect(tool.props.inputSchema).toBe(inputSchema);
|
|
218
|
+
expect(tool.props.outputSchema).toBe(outputSchema);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should use default description when not provided", () => {
|
|
222
|
+
const tool = mockTool({
|
|
223
|
+
name: "test_tool",
|
|
224
|
+
inputSchema: z.object({}),
|
|
225
|
+
response: {},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(tool.props.description).toBe("Mock tool: test_tool");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should include permissions in props", () => {
|
|
232
|
+
const tool = mockTool({
|
|
233
|
+
name: "admin_tool",
|
|
234
|
+
inputSchema: z.object({}),
|
|
235
|
+
permissions: "admin",
|
|
236
|
+
response: {},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(tool.props.permissions).toBe("admin");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should handle array permissions", () => {
|
|
243
|
+
const tool = mockTool({
|
|
244
|
+
name: "multi_perm_tool",
|
|
245
|
+
inputSchema: z.object({}),
|
|
246
|
+
permissions: ["admin", "moderator"],
|
|
247
|
+
response: {},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(tool.props.permissions).toEqual(["admin", "moderator"]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("integration with custom function", () => {
|
|
255
|
+
it("should track invocations for custom function", async () => {
|
|
256
|
+
const tool = mockTool({
|
|
257
|
+
name: "custom_tool",
|
|
258
|
+
inputSchema: z.object({ value: z.number() }),
|
|
259
|
+
fn: async ({ input }) => ({
|
|
260
|
+
success: true,
|
|
261
|
+
data: { doubled: input.value * 2 },
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const ctx = mockToolContext();
|
|
266
|
+
await tool.fn({ input: { value: 5 }, ctx });
|
|
267
|
+
await tool.fn({ input: { value: 10 }, ctx });
|
|
268
|
+
|
|
269
|
+
expect(tool.invocations.length).toBe(2);
|
|
270
|
+
expect(tool.invocations[0].input.value).toBe(5);
|
|
271
|
+
expect(tool.invocations[1].input.value).toBe(10);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should prioritize custom function over canned response", async () => {
|
|
275
|
+
const tool = mockTool<{}, { canned?: boolean; custom?: boolean }>({
|
|
276
|
+
name: "priority_tool",
|
|
277
|
+
inputSchema: z.object({}),
|
|
278
|
+
response: { canned: true },
|
|
279
|
+
fn: async () => ({ success: true, data: { custom: true } }),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const ctx = mockToolContext();
|
|
283
|
+
const result = await tool.fn({ input: {}, ctx });
|
|
284
|
+
|
|
285
|
+
expect(result.success).toBe(true);
|
|
286
|
+
if (result.success) {
|
|
287
|
+
expect(result.data).toEqual({ custom: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("type inference", () => {
|
|
293
|
+
it("should infer input and output types", async () => {
|
|
294
|
+
const tool = mockTool({
|
|
295
|
+
name: "typed_tool",
|
|
296
|
+
inputSchema: z.object({
|
|
297
|
+
city: z.string(),
|
|
298
|
+
country: z.string().optional(),
|
|
299
|
+
}),
|
|
300
|
+
response: { temperature: 22, conditions: "sunny" },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const ctx = mockToolContext();
|
|
304
|
+
const result = await tool.fn({
|
|
305
|
+
input: { city: "Stockholm" },
|
|
306
|
+
ctx,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// TypeScript should enforce these types
|
|
310
|
+
expect(result.success).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Message, ToolCall } from "@flink-app/flink/ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fluent builder for multi-turn conversation testing
|
|
5
|
+
* Simplifies creating conversation histories
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const conversation = createConversation()
|
|
9
|
+
* .user("Book a flight to Paris")
|
|
10
|
+
* .assistant("When would you like to travel?")
|
|
11
|
+
* .user("Next Monday")
|
|
12
|
+
* .assistant("Let me search", [
|
|
13
|
+
* { id: "1", name: "search_flights", input: { destination: "Paris" } }
|
|
14
|
+
* ])
|
|
15
|
+
* .tool("1", "search_flights", JSON.stringify({ flights: [...] }))
|
|
16
|
+
* .assistant("I found these flights...")
|
|
17
|
+
* .build();
|
|
18
|
+
*/
|
|
19
|
+
export interface ConversationBuilder {
|
|
20
|
+
user(content: string): ConversationBuilder;
|
|
21
|
+
assistant(content: string, toolCalls?: ToolCall[]): ConversationBuilder;
|
|
22
|
+
tool(toolCallId: string, toolName: string, result: string): ConversationBuilder;
|
|
23
|
+
build(): Message[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ConversationBuilderImpl implements ConversationBuilder {
|
|
27
|
+
private messages: Message[] = [];
|
|
28
|
+
|
|
29
|
+
user(content: string): ConversationBuilder {
|
|
30
|
+
this.messages.push({ role: "user", content });
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
assistant(content: string, toolCalls?: ToolCall[]): ConversationBuilder {
|
|
35
|
+
this.messages.push({ role: "assistant", content, toolCalls });
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tool(toolCallId: string, toolName: string, result: string): ConversationBuilder {
|
|
40
|
+
this.messages.push({ role: "tool", toolCallId, toolName, result });
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
build(): Message[] {
|
|
45
|
+
return this.messages;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new conversation builder
|
|
51
|
+
*/
|
|
52
|
+
export function createConversation(): ConversationBuilder {
|
|
53
|
+
return new ConversationBuilderImpl();
|
|
54
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { FlinkContext } from "@flink-app/flink";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a mock FlinkContext for tool testing
|
|
5
|
+
* Provides sensible defaults for all required properties
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const ctx = mockToolContext({
|
|
9
|
+
* repos: { carRepo: mockCarRepo },
|
|
10
|
+
* plugins: { email: mockEmailPlugin }
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Minimal usage with defaults
|
|
15
|
+
* const ctx = mockToolContext();
|
|
16
|
+
*/
|
|
17
|
+
export function mockToolContext<Ctx extends FlinkContext = FlinkContext>(overrides: Partial<Ctx> = {}): Ctx {
|
|
18
|
+
const defaultContext: FlinkContext = {
|
|
19
|
+
repos: {},
|
|
20
|
+
plugins: {},
|
|
21
|
+
agents: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
...defaultContext,
|
|
26
|
+
...overrides,
|
|
27
|
+
// Deep merge repos, plugins, and agents
|
|
28
|
+
repos: {
|
|
29
|
+
...defaultContext.repos,
|
|
30
|
+
...(overrides.repos || {}),
|
|
31
|
+
},
|
|
32
|
+
plugins: {
|
|
33
|
+
...defaultContext.plugins,
|
|
34
|
+
...(overrides.plugins || {}),
|
|
35
|
+
},
|
|
36
|
+
agents: {
|
|
37
|
+
...(defaultContext.agents || {}),
|
|
38
|
+
...((overrides as any).agents || {}),
|
|
39
|
+
},
|
|
40
|
+
} as Ctx;
|
|
41
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { LLMAdapter, LLMMessage, LLMStreamChunk } from "@flink-app/flink/ai";
|
|
2
|
+
|
|
3
|
+
export interface MockLLMConfig {
|
|
4
|
+
// Simple single response
|
|
5
|
+
response?: string;
|
|
6
|
+
|
|
7
|
+
// Multiple responses for multi-turn (queued)
|
|
8
|
+
responses?: Array<{
|
|
9
|
+
text?: string;
|
|
10
|
+
toolCalls?: Array<{ id: string; name: string; input: any }>;
|
|
11
|
+
stopReason?: "end_turn" | "tool_use" | "max_tokens";
|
|
12
|
+
streamChunks?: LLMStreamChunk[]; // Explicitly define streaming chunks
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
// Error simulation
|
|
16
|
+
error?: Error;
|
|
17
|
+
|
|
18
|
+
// Token usage simulation
|
|
19
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
20
|
+
|
|
21
|
+
// Default stream chunks if not specified per response
|
|
22
|
+
defaultStreamChunks?: LLMStreamChunk[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MockLLMAdapterInvocation {
|
|
26
|
+
instructions: string;
|
|
27
|
+
messages: LLMMessage[];
|
|
28
|
+
tools: any[];
|
|
29
|
+
maxTokens: number;
|
|
30
|
+
temperature: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MockLLMAdapter extends LLMAdapter {
|
|
34
|
+
// Automatic invocation tracking
|
|
35
|
+
invocations: MockLLMAdapterInvocation[];
|
|
36
|
+
|
|
37
|
+
// Utilities
|
|
38
|
+
reset(): void;
|
|
39
|
+
getLastInvocation(): MockLLMAdapterInvocation | undefined;
|
|
40
|
+
getInvocationCount(): number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a mock LLM adapter for testing agents without API calls
|
|
45
|
+
*
|
|
46
|
+
* Features:
|
|
47
|
+
* - Canned responses (single or multi-turn)
|
|
48
|
+
* - Tool call simulation
|
|
49
|
+
* - Error simulation
|
|
50
|
+
* - Automatic invocation tracking
|
|
51
|
+
* - Token usage simulation
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // Simple response
|
|
55
|
+
* const adapter = mockLLMAdapter({ response: "Hello!" });
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Multi-turn with tool calls
|
|
59
|
+
* const adapter = mockLLMAdapter({
|
|
60
|
+
* responses: [
|
|
61
|
+
* {
|
|
62
|
+
* text: "Let me check",
|
|
63
|
+
* toolCalls: [{ id: "1", name: "get_weather", input: { city: "Stockholm" } }]
|
|
64
|
+
* },
|
|
65
|
+
* { text: "It's sunny and 22°C" }
|
|
66
|
+
* ]
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // Error simulation
|
|
71
|
+
* const adapter = mockLLMAdapter({
|
|
72
|
+
* error: new Error("Rate limit exceeded")
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export function mockLLMAdapter(config: MockLLMConfig = {}): MockLLMAdapter {
|
|
76
|
+
const invocations: MockLLMAdapterInvocation[] = [];
|
|
77
|
+
let responseQueue: Array<{
|
|
78
|
+
text?: string;
|
|
79
|
+
toolCalls?: Array<{ id: string; name: string; input: any }>;
|
|
80
|
+
stopReason?: "end_turn" | "tool_use" | "max_tokens";
|
|
81
|
+
streamChunks?: LLMStreamChunk[];
|
|
82
|
+
}> = [];
|
|
83
|
+
let currentResponseIndex = 0;
|
|
84
|
+
|
|
85
|
+
// Initialize response queue
|
|
86
|
+
if (config.response) {
|
|
87
|
+
responseQueue = [{ text: config.response, stopReason: "end_turn" }];
|
|
88
|
+
} else if (config.responses) {
|
|
89
|
+
responseQueue = config.responses;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const defaultUsage = config.usage || { inputTokens: 10, outputTokens: 20 };
|
|
93
|
+
|
|
94
|
+
const stream = async function* (params: {
|
|
95
|
+
instructions: string;
|
|
96
|
+
messages: LLMMessage[];
|
|
97
|
+
tools: any[];
|
|
98
|
+
maxTokens: number;
|
|
99
|
+
temperature: number;
|
|
100
|
+
}): AsyncGenerator<LLMStreamChunk> {
|
|
101
|
+
// Track invocation
|
|
102
|
+
invocations.push({
|
|
103
|
+
instructions: params.instructions,
|
|
104
|
+
messages: params.messages,
|
|
105
|
+
tools: params.tools,
|
|
106
|
+
maxTokens: params.maxTokens,
|
|
107
|
+
temperature: params.temperature,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Simulate error if configured
|
|
111
|
+
if (config.error) {
|
|
112
|
+
throw config.error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Get next response from queue
|
|
116
|
+
const responseConfig = responseQueue[currentResponseIndex] || responseQueue[responseQueue.length - 1];
|
|
117
|
+
if (!responseConfig) {
|
|
118
|
+
throw new Error("No responses configured for mockLLMAdapter");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
currentResponseIndex++;
|
|
122
|
+
|
|
123
|
+
// Use explicit stream chunks if provided
|
|
124
|
+
if (responseConfig.streamChunks) {
|
|
125
|
+
for (const chunk of responseConfig.streamChunks) {
|
|
126
|
+
yield chunk;
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Use default stream chunks if provided
|
|
132
|
+
if (config.defaultStreamChunks) {
|
|
133
|
+
for (const chunk of config.defaultStreamChunks) {
|
|
134
|
+
yield chunk;
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Otherwise, generate chunks from response
|
|
140
|
+
if (responseConfig.text) {
|
|
141
|
+
yield {
|
|
142
|
+
type: "text",
|
|
143
|
+
delta: responseConfig.text
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const toolCall of responseConfig.toolCalls || []) {
|
|
148
|
+
yield { type: "tool_call", toolCall };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
yield {
|
|
152
|
+
type: "usage",
|
|
153
|
+
usage: defaultUsage,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Determine stop reason
|
|
157
|
+
let stopReason: "end_turn" | "tool_use" | "max_tokens" = "end_turn";
|
|
158
|
+
if (responseConfig.stopReason) {
|
|
159
|
+
stopReason = responseConfig.stopReason;
|
|
160
|
+
} else if (responseConfig.toolCalls && responseConfig.toolCalls.length > 0) {
|
|
161
|
+
stopReason = "tool_use";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
yield {
|
|
165
|
+
type: "done",
|
|
166
|
+
stopReason,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const reset = (): void => {
|
|
171
|
+
invocations.length = 0;
|
|
172
|
+
currentResponseIndex = 0;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const getLastInvocation = (): MockLLMAdapterInvocation | undefined => {
|
|
176
|
+
return invocations[invocations.length - 1];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const getInvocationCount = (): number => {
|
|
180
|
+
return invocations.length;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
stream,
|
|
185
|
+
invocations,
|
|
186
|
+
reset,
|
|
187
|
+
getLastInvocation,
|
|
188
|
+
getInvocationCount,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Helper function to create streaming chunks for testing
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* const chunks = createStreamingChunks({
|
|
197
|
+
* text: "Hello world",
|
|
198
|
+
* chunkText: true,
|
|
199
|
+
* toolCalls: [{ id: "1", name: "search", input: { query: "test" } }]
|
|
200
|
+
* });
|
|
201
|
+
*/
|
|
202
|
+
export function createStreamingChunks(config: {
|
|
203
|
+
text?: string;
|
|
204
|
+
toolCalls?: Array<{ id: string; name: string; input: any }>;
|
|
205
|
+
chunkText?: boolean; // Split text into multiple deltas
|
|
206
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
207
|
+
stopReason?: "end_turn" | "tool_use" | "max_tokens";
|
|
208
|
+
}): LLMStreamChunk[] {
|
|
209
|
+
const chunks: LLMStreamChunk[] = [];
|
|
210
|
+
|
|
211
|
+
if (config.text) {
|
|
212
|
+
if (config.chunkText) {
|
|
213
|
+
// Split into multiple text deltas (by words)
|
|
214
|
+
const words = config.text.split(' ');
|
|
215
|
+
for (const word of words) {
|
|
216
|
+
chunks.push({ type: "text", delta: word + ' ' });
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
chunks.push({ type: "text", delta: config.text });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const toolCall of config.toolCalls || []) {
|
|
224
|
+
chunks.push({ type: "tool_call", toolCall });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
chunks.push({
|
|
228
|
+
type: "usage",
|
|
229
|
+
usage: config.usage || { inputTokens: 100, outputTokens: 50 },
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
chunks.push({
|
|
233
|
+
type: "done",
|
|
234
|
+
stopReason: config.stopReason || (config.toolCalls && config.toolCalls.length > 0 ? "tool_use" : "end_turn"),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return chunks;
|
|
238
|
+
}
|