@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.
@@ -0,0 +1,533 @@
1
+ import { mockLLMAdapter, createStreamingChunks } from "../../src/ai/mockLLMAdapter";
2
+ import { LLMStreamChunk } from "@flink-app/flink/ai";
3
+
4
+ // Helper to consume stream and extract result
5
+ async function consumeStream(adapter: any, params: any) {
6
+ const chunks: LLMStreamChunk[] = [];
7
+ for await (const chunk of adapter.stream(params)) {
8
+ chunks.push(chunk);
9
+ }
10
+
11
+ const textChunks = chunks.filter(c => c.type === "text");
12
+ const toolCallChunks = chunks.filter(c => c.type === "tool_call");
13
+ const usageChunk = chunks.find(c => c.type === "usage") as any;
14
+ const doneChunk = chunks.find(c => c.type === "done") as any;
15
+
16
+ return {
17
+ textContent: textChunks.length > 0 ? textChunks.map((c: any) => c.delta).join("") : undefined,
18
+ toolCalls: toolCallChunks.map((c: any) => c.toolCall),
19
+ usage: usageChunk?.usage,
20
+ stopReason: doneChunk?.stopReason,
21
+ chunks,
22
+ };
23
+ }
24
+
25
+ describe("mockLLMAdapter", () => {
26
+ describe("simple response", () => {
27
+ it("should return canned response", async () => {
28
+ const adapter = mockLLMAdapter({ response: "Hello!" });
29
+
30
+ const result = await consumeStream(adapter, {
31
+ instructions: "You are helpful",
32
+ messages: [{ role: "user", content: "Hi" }],
33
+ tools: [],
34
+ maxTokens: 1024,
35
+ temperature: 0.7,
36
+ });
37
+
38
+ expect(result.textContent).toBe("Hello!");
39
+ expect(result.toolCalls).toEqual([]);
40
+ expect(result.stopReason).toBe("end_turn");
41
+ expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 20 });
42
+ });
43
+
44
+ it("should track invocations", async () => {
45
+ const adapter = mockLLMAdapter({ response: "Test" });
46
+
47
+ await consumeStream(adapter, {
48
+ instructions: "System",
49
+ messages: [{ role: "user", content: "Hello" }],
50
+ tools: [],
51
+ maxTokens: 1024,
52
+ temperature: 0.7,
53
+ });
54
+
55
+ expect(adapter.invocations.length).toBe(1);
56
+ expect(adapter.getInvocationCount()).toBe(1);
57
+ expect(adapter.getLastInvocation()?.instructions).toBe("System");
58
+ expect(adapter.getLastInvocation()?.messages[0]).toEqual({
59
+ role: "user",
60
+ content: "Hello",
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("multi-turn responses", () => {
66
+ it("should queue multiple responses", async () => {
67
+ const adapter = mockLLMAdapter({
68
+ responses: [
69
+ { text: "First response" },
70
+ { text: "Second response" },
71
+ { text: "Third response" },
72
+ ],
73
+ });
74
+
75
+ const result1 = await consumeStream(adapter, {
76
+ instructions: "System",
77
+ messages: [{ role: "user", content: "1" }],
78
+ tools: [],
79
+ maxTokens: 1024,
80
+ temperature: 0.7,
81
+ });
82
+
83
+ const result2 = await consumeStream(adapter, {
84
+ instructions: "System",
85
+ messages: [{ role: "user", content: "2" }],
86
+ tools: [],
87
+ maxTokens: 1024,
88
+ temperature: 0.7,
89
+ });
90
+
91
+ const result3 = await consumeStream(adapter, {
92
+ instructions: "System",
93
+ messages: [{ role: "user", content: "3" }],
94
+ tools: [],
95
+ maxTokens: 1024,
96
+ temperature: 0.7,
97
+ });
98
+
99
+ expect(result1.textContent).toBe("First response");
100
+ expect(result2.textContent).toBe("Second response");
101
+ expect(result3.textContent).toBe("Third response");
102
+ expect(adapter.invocations.length).toBe(3);
103
+ });
104
+
105
+ it("should reuse last response when queue exhausted", async () => {
106
+ const adapter = mockLLMAdapter({
107
+ responses: [{ text: "First" }, { text: "Last" }],
108
+ });
109
+
110
+ await consumeStream(adapter, {
111
+ instructions: "System",
112
+ messages: [],
113
+ tools: [],
114
+ maxTokens: 1024,
115
+ temperature: 0.7,
116
+ });
117
+ await consumeStream(adapter, {
118
+ instructions: "System",
119
+ messages: [],
120
+ tools: [],
121
+ maxTokens: 1024,
122
+ temperature: 0.7,
123
+ });
124
+ const result3 = await consumeStream(adapter, {
125
+ instructions: "System",
126
+ messages: [],
127
+ tools: [],
128
+ maxTokens: 1024,
129
+ temperature: 0.7,
130
+ });
131
+
132
+ expect(result3.textContent).toBe("Last");
133
+ });
134
+ });
135
+
136
+ describe("tool calls", () => {
137
+ it("should return tool calls", async () => {
138
+ const adapter = mockLLMAdapter({
139
+ responses: [
140
+ {
141
+ text: "Let me check",
142
+ toolCalls: [
143
+ { id: "1", name: "get_weather", input: { city: "Stockholm" } },
144
+ ],
145
+ },
146
+ ],
147
+ });
148
+
149
+ const result = await consumeStream(adapter, {
150
+ instructions: "System",
151
+ messages: [{ role: "user", content: "Weather?" }],
152
+ tools: [],
153
+ maxTokens: 1024,
154
+ temperature: 0.7,
155
+ });
156
+
157
+ expect(result.textContent).toBe("Let me check");
158
+ expect(result.toolCalls).toEqual([
159
+ { id: "1", name: "get_weather", input: { city: "Stockholm" } },
160
+ ]);
161
+ expect(result.stopReason).toBe("tool_use");
162
+ });
163
+
164
+ it("should set stop reason to tool_use when tool calls present", async () => {
165
+ const adapter = mockLLMAdapter({
166
+ responses: [
167
+ {
168
+ toolCalls: [{ id: "1", name: "test", input: {} }],
169
+ },
170
+ ],
171
+ });
172
+
173
+ const result = await consumeStream(adapter, {
174
+ instructions: "System",
175
+ messages: [],
176
+ tools: [],
177
+ maxTokens: 1024,
178
+ temperature: 0.7,
179
+ });
180
+
181
+ expect(result.stopReason).toBe("tool_use");
182
+ });
183
+
184
+ it("should respect explicit stop reason", async () => {
185
+ const adapter = mockLLMAdapter({
186
+ responses: [
187
+ {
188
+ text: "Partial",
189
+ stopReason: "max_tokens",
190
+ },
191
+ ],
192
+ });
193
+
194
+ const result = await consumeStream(adapter, {
195
+ instructions: "System",
196
+ messages: [],
197
+ tools: [],
198
+ maxTokens: 1024,
199
+ temperature: 0.7,
200
+ });
201
+
202
+ expect(result.stopReason).toBe("max_tokens");
203
+ });
204
+ });
205
+
206
+ describe("error simulation", () => {
207
+ it("should throw configured error", async () => {
208
+ const testError = new Error("Rate limit exceeded");
209
+ const adapter = mockLLMAdapter({ error: testError });
210
+
211
+ await expectAsync(
212
+ consumeStream(adapter, {
213
+ instructions: "System",
214
+ messages: [],
215
+ tools: [],
216
+ maxTokens: 1024,
217
+ temperature: 0.7,
218
+ })
219
+ ).toBeRejectedWith(testError);
220
+ });
221
+
222
+ it("should track invocations before throwing", async () => {
223
+ const adapter = mockLLMAdapter({ error: new Error("Test error") });
224
+
225
+ try {
226
+ await consumeStream(adapter, {
227
+ instructions: "System",
228
+ messages: [{ role: "user", content: "Test" }],
229
+ tools: [],
230
+ maxTokens: 1024,
231
+ temperature: 0.7,
232
+ });
233
+ } catch (e) {
234
+ // Expected
235
+ }
236
+
237
+ expect(adapter.invocations.length).toBe(1);
238
+ expect(adapter.getLastInvocation()?.messages[0]).toEqual({
239
+ role: "user",
240
+ content: "Test",
241
+ });
242
+ });
243
+ });
244
+
245
+ describe("utilities", () => {
246
+ it("should reset invocations", async () => {
247
+ const adapter = mockLLMAdapter({ response: "Test" });
248
+
249
+ await consumeStream(adapter, {
250
+ instructions: "System",
251
+ messages: [],
252
+ tools: [],
253
+ maxTokens: 1024,
254
+ temperature: 0.7,
255
+ });
256
+ await consumeStream(adapter, {
257
+ instructions: "System",
258
+ messages: [],
259
+ tools: [],
260
+ maxTokens: 1024,
261
+ temperature: 0.7,
262
+ });
263
+
264
+ expect(adapter.invocations.length).toBe(2);
265
+
266
+ adapter.reset();
267
+
268
+ expect(adapter.invocations.length).toBe(0);
269
+ expect(adapter.getInvocationCount()).toBe(0);
270
+ expect(adapter.getLastInvocation()).toBeUndefined();
271
+ });
272
+
273
+ it("should reset response queue index", async () => {
274
+ const adapter = mockLLMAdapter({
275
+ responses: [{ text: "First" }, { text: "Second" }],
276
+ });
277
+
278
+ await consumeStream(adapter, {
279
+ instructions: "System",
280
+ messages: [],
281
+ tools: [],
282
+ maxTokens: 1024,
283
+ temperature: 0.7,
284
+ });
285
+ await consumeStream(adapter, {
286
+ instructions: "System",
287
+ messages: [],
288
+ tools: [],
289
+ maxTokens: 1024,
290
+ temperature: 0.7,
291
+ });
292
+
293
+ adapter.reset();
294
+
295
+ const result = await consumeStream(adapter, {
296
+ instructions: "System",
297
+ messages: [],
298
+ tools: [],
299
+ maxTokens: 1024,
300
+ temperature: 0.7,
301
+ });
302
+
303
+ expect(result.textContent).toBe("First");
304
+ });
305
+ });
306
+
307
+ describe("custom usage", () => {
308
+ it("should use custom token usage", async () => {
309
+ const adapter = mockLLMAdapter({
310
+ response: "Test",
311
+ usage: { inputTokens: 100, outputTokens: 50 },
312
+ });
313
+
314
+ const result = await consumeStream(adapter, {
315
+ instructions: "System",
316
+ messages: [],
317
+ tools: [],
318
+ maxTokens: 1024,
319
+ temperature: 0.7,
320
+ });
321
+
322
+ expect(result.usage).toEqual({ inputTokens: 100, outputTokens: 50 });
323
+ });
324
+ });
325
+
326
+ describe("streaming", () => {
327
+ it("should stream text deltas progressively", async () => {
328
+ const adapter = mockLLMAdapter({
329
+ responses: [{
330
+ text: "Hello world",
331
+ streamChunks: [
332
+ { type: "text", delta: "Hello " },
333
+ { type: "text", delta: "world" },
334
+ { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } },
335
+ { type: "done", stopReason: "end_turn" },
336
+ ],
337
+ }],
338
+ });
339
+
340
+ const chunks: any[] = [];
341
+ for await (const chunk of adapter.stream({
342
+ instructions: "Test",
343
+ messages: [{ role: "user", content: "Hello" }],
344
+ tools: [],
345
+ maxTokens: 100,
346
+ temperature: 0.7,
347
+ })) {
348
+ chunks.push(chunk);
349
+ }
350
+
351
+ expect(chunks).toEqual([
352
+ { type: "text", delta: "Hello " },
353
+ { type: "text", delta: "world" },
354
+ { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } },
355
+ { type: "done", stopReason: "end_turn" },
356
+ ]);
357
+ });
358
+
359
+ it("should stream tool calls", async () => {
360
+ const adapter = mockLLMAdapter({
361
+ responses: [{
362
+ text: "",
363
+ toolCalls: [
364
+ { id: "tool1", name: "search", input: { query: "test" } },
365
+ ],
366
+ streamChunks: [
367
+ {
368
+ type: "tool_call",
369
+ toolCall: { id: "tool1", name: "search", input: { query: "test" } }
370
+ },
371
+ { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } },
372
+ { type: "done", stopReason: "tool_use" },
373
+ ],
374
+ }],
375
+ });
376
+
377
+ const chunks: any[] = [];
378
+ for await (const chunk of adapter.stream({
379
+ instructions: "Test",
380
+ messages: [{ role: "user", content: "Search" }],
381
+ tools: [{ name: "search", description: "Search", inputSchema: {} }],
382
+ maxTokens: 100,
383
+ temperature: 0.7,
384
+ })) {
385
+ chunks.push(chunk);
386
+ }
387
+
388
+ expect(chunks.length).toBe(3);
389
+ expect(chunks[0].type).toBe("tool_call");
390
+ expect(chunks[1].type).toBe("usage");
391
+ expect(chunks[2].type).toBe("done");
392
+ });
393
+
394
+ it("should generate chunks from response if streamChunks not provided", async () => {
395
+ const adapter = mockLLMAdapter({
396
+ responses: [{
397
+ text: "Hello world",
398
+ toolCalls: [],
399
+ }],
400
+ usage: { inputTokens: 10, outputTokens: 5 },
401
+ });
402
+
403
+ const chunks: any[] = [];
404
+ for await (const chunk of adapter.stream({
405
+ instructions: "Test",
406
+ messages: [{ role: "user", content: "Hello" }],
407
+ tools: [],
408
+ maxTokens: 100,
409
+ temperature: 0.7,
410
+ })) {
411
+ chunks.push(chunk);
412
+ }
413
+
414
+ expect(chunks.length).toBe(3); // text, usage, done
415
+ expect(chunks[0].type).toBe("text");
416
+ expect(chunks[0].delta).toBe("Hello world");
417
+ expect(chunks[1].type).toBe("usage");
418
+ expect(chunks[2].type).toBe("done");
419
+ });
420
+
421
+ it("should track invocations for streaming", async () => {
422
+ const adapter = mockLLMAdapter({
423
+ responses: [{ text: "Test" }],
424
+ });
425
+
426
+ const generator = adapter.stream({
427
+ instructions: "Stream system",
428
+ messages: [{ role: "user", content: "Stream test" }],
429
+ tools: [],
430
+ maxTokens: 1024,
431
+ temperature: 0.7,
432
+ });
433
+
434
+ // Consume the stream
435
+ for await (const chunk of generator) {
436
+ // Just consume
437
+ }
438
+
439
+ expect(adapter.invocations.length).toBe(1);
440
+ expect(adapter.getLastInvocation()?.instructions).toBe(
441
+ "Stream system"
442
+ );
443
+ });
444
+ });
445
+
446
+ describe("edge cases", () => {
447
+ it("should throw when no responses configured", async () => {
448
+ const adapter = mockLLMAdapter({});
449
+
450
+ await expectAsync(
451
+ consumeStream(adapter, {
452
+ instructions: "System",
453
+ messages: [],
454
+ tools: [],
455
+ maxTokens: 1024,
456
+ temperature: 0.7,
457
+ })
458
+ ).toBeRejectedWithError("No responses configured for mockLLMAdapter");
459
+ });
460
+
461
+ it("should handle undefined text content", async () => {
462
+ const adapter = mockLLMAdapter({
463
+ responses: [{ toolCalls: [{ id: "1", name: "test", input: {} }] }],
464
+ });
465
+
466
+ const result = await consumeStream(adapter, {
467
+ instructions: "System",
468
+ messages: [],
469
+ tools: [],
470
+ maxTokens: 1024,
471
+ temperature: 0.7,
472
+ });
473
+
474
+ expect(result.textContent).toBeUndefined();
475
+ expect(result.toolCalls.length).toBe(1);
476
+ });
477
+ });
478
+
479
+ describe("createStreamingChunks helper", () => {
480
+ it("should create chunks with single text delta", () => {
481
+ const chunks = createStreamingChunks({
482
+ text: "Hello world",
483
+ chunkText: false,
484
+ });
485
+
486
+ expect(chunks.length).toBe(3); // text, usage, done
487
+ expect(chunks[0]).toEqual({ type: "text", delta: "Hello world" });
488
+ expect(chunks[1].type).toBe("usage");
489
+ expect(chunks[2].type).toBe("done");
490
+ });
491
+
492
+ it("should create chunks with multiple text deltas", () => {
493
+ const chunks = createStreamingChunks({
494
+ text: "Hello world",
495
+ chunkText: true,
496
+ });
497
+
498
+ expect(chunks.filter(c => c.type === "text").length).toBeGreaterThan(1);
499
+ const textChunks = chunks.filter(c => c.type === "text");
500
+ const fullText = textChunks.map((c: any) => c.delta).join("");
501
+ expect(fullText).toBe("Hello world ");
502
+ expect(chunks.some(c => c.type === "usage")).toBe(true);
503
+ expect(chunks.some(c => c.type === "done")).toBe(true);
504
+ });
505
+
506
+ it("should create chunks with tool calls", () => {
507
+ const chunks = createStreamingChunks({
508
+ text: "Searching...",
509
+ toolCalls: [
510
+ { id: "1", name: "search", input: { query: "test" } },
511
+ ],
512
+ });
513
+
514
+ const toolCallChunk = chunks.find(c => c.type === "tool_call");
515
+ expect(toolCallChunk).toBeDefined();
516
+ expect((toolCallChunk as any).toolCall.name).toBe("search");
517
+ });
518
+
519
+ it("should create chunks with custom usage and stop reason", () => {
520
+ const chunks = createStreamingChunks({
521
+ text: "Done",
522
+ usage: { inputTokens: 200, outputTokens: 100 },
523
+ stopReason: "max_tokens",
524
+ });
525
+
526
+ const usageChunk = chunks.find(c => c.type === "usage");
527
+ const doneChunk = chunks.find(c => c.type === "done");
528
+
529
+ expect((usageChunk as any).usage).toEqual({ inputTokens: 200, outputTokens: 100 });
530
+ expect((doneChunk as any).stopReason).toBe("max_tokens");
531
+ });
532
+ });
533
+ });