@mohanscodex/spectra-agent 0.4.0

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,854 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Agent } from "../agent.js";
3
+ import { defineTool } from "../define-tool.js";
4
+ import { z } from "zod";
5
+ import { AssistantMessageEventStream, registerProvider } from "@mohanscodex/spectra-ai";
6
+ // Test model
7
+ const testModel = {
8
+ id: "claude-sonnet-4-20250514",
9
+ name: "Claude Sonnet 4",
10
+ provider: "test-provider",
11
+ api: "test",
12
+ };
13
+ // Helper to create a mock provider that returns specific events
14
+ function createMockProvider(name, responseSequence) {
15
+ let callIndex = 0;
16
+ return {
17
+ name,
18
+ stream(model, context) {
19
+ const stream = new AssistantMessageEventStream();
20
+ const responses = responseSequence[callIndex] || [];
21
+ callIndex++;
22
+ setTimeout(() => {
23
+ const partial = {
24
+ role: "assistant",
25
+ content: [],
26
+ provider: model.provider,
27
+ model: model.id,
28
+ usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30 },
29
+ stopReason: "stop",
30
+ timestamp: Date.now(),
31
+ };
32
+ stream.push({ type: "start", partial });
33
+ // Stream text content
34
+ for (let i = 0; i < responses.length; i++) {
35
+ const msg = responses[i];
36
+ for (const block of msg.content) {
37
+ if (block.type === "text") {
38
+ stream.push({
39
+ type: "text_delta",
40
+ contentIndex: i,
41
+ delta: block.text,
42
+ partial: { ...partial, content: [block] },
43
+ });
44
+ }
45
+ else if (block.type === "toolCall") {
46
+ stream.push({
47
+ type: "toolcall_start",
48
+ contentIndex: i,
49
+ partial: { ...partial, content: [block] },
50
+ });
51
+ stream.push({
52
+ type: "toolcall_end",
53
+ contentIndex: i,
54
+ toolCall: block,
55
+ partial: { ...partial, content: [block] },
56
+ });
57
+ }
58
+ }
59
+ }
60
+ // Use the last response's stop reason
61
+ const lastResponse = responses[responses.length - 1] || partial;
62
+ stream.push({
63
+ type: "done",
64
+ reason: lastResponse.stopReason,
65
+ message: lastResponse,
66
+ });
67
+ stream.end();
68
+ }, 10);
69
+ return stream;
70
+ },
71
+ };
72
+ }
73
+ // Helper to create text-only assistant message
74
+ function createTextMessage(text, stopReason = "stop") {
75
+ return {
76
+ role: "assistant",
77
+ content: [{ type: "text", text }],
78
+ provider: "test-provider",
79
+ model: "test-model",
80
+ usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30 },
81
+ stopReason,
82
+ timestamp: Date.now(),
83
+ };
84
+ }
85
+ // Helper to create tool call message
86
+ function createToolCallMessage(toolCalls) {
87
+ return {
88
+ role: "assistant",
89
+ content: toolCalls,
90
+ provider: "test-provider",
91
+ model: "test-model",
92
+ usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30 },
93
+ stopReason: "toolUse",
94
+ timestamp: Date.now(),
95
+ };
96
+ }
97
+ describe("Agent E2E - Basic Conversation", () => {
98
+ beforeEach(() => {
99
+ // Clear and re-register mock provider
100
+ });
101
+ it("should run simple conversation without tools", async () => {
102
+ const mockProvider = createMockProvider("test-provider", [
103
+ [createTextMessage("Hello! How can I help you?")],
104
+ ]);
105
+ registerProvider(mockProvider);
106
+ const agent = new Agent({
107
+ model: testModel,
108
+ systemPrompt: "You are a helpful assistant.",
109
+ });
110
+ const events = [];
111
+ for await (const event of agent.run("Hi!")) {
112
+ events.push(event);
113
+ }
114
+ // Should have agent_start, message_start, message_end, turn_start, message_start, message_update(s), message_end, turn_end, agent_end
115
+ expect(events.length).toBeGreaterThan(0);
116
+ const eventTypes = events.map((e) => e.type);
117
+ expect(eventTypes).toContain("agent_start");
118
+ expect(eventTypes).toContain("agent_end");
119
+ expect(eventTypes).toContain("turn_start");
120
+ expect(eventTypes).toContain("turn_end");
121
+ });
122
+ it("should maintain message history across turns", async () => {
123
+ const responses = [
124
+ [createTextMessage("First response")],
125
+ [createTextMessage("Second response")],
126
+ ];
127
+ const mockProvider = createMockProvider("test-provider", responses);
128
+ registerProvider(mockProvider);
129
+ const agent = new Agent({
130
+ model: testModel,
131
+ });
132
+ // First run
133
+ for await (const _ of agent.run("Message 1")) {
134
+ // consume
135
+ }
136
+ const firstHistoryLength = agent.messages.length;
137
+ // Second run
138
+ for await (const _ of agent.run("Message 2")) {
139
+ // consume
140
+ }
141
+ const secondHistoryLength = agent.messages.length;
142
+ // History should accumulate
143
+ expect(secondHistoryLength).toBeGreaterThan(firstHistoryLength);
144
+ });
145
+ it("should emit message events with correct structure", async () => {
146
+ const mockProvider = createMockProvider("test-provider", [
147
+ [createTextMessage("Test response")],
148
+ ]);
149
+ registerProvider(mockProvider);
150
+ const agent = new Agent({
151
+ model: testModel,
152
+ });
153
+ const events = [];
154
+ for await (const event of agent.run("Test")) {
155
+ events.push(event);
156
+ }
157
+ const messageStart = events.find((e) => e.type === "message_start");
158
+ const messageEnd = events.find((e) => e.type === "message_end");
159
+ expect(messageStart).toBeDefined();
160
+ expect(messageEnd).toBeDefined();
161
+ expect(messageStart.message.role).toBeDefined();
162
+ expect(messageEnd.message.role).toBeDefined();
163
+ });
164
+ });
165
+ describe("Agent E2E - Tool Execution", () => {
166
+ it("should execute single tool call", async () => {
167
+ const tool = defineTool({
168
+ name: "get_weather",
169
+ description: "Get weather information",
170
+ parameters: z.object({
171
+ location: z.string().describe("The location"),
172
+ }),
173
+ execute: async (args) => {
174
+ return {
175
+ content: [{ type: "text", text: `Weather in ${args.location}: Sunny` }],
176
+ };
177
+ },
178
+ });
179
+ const mockProvider = createMockProvider("test-provider", [
180
+ [
181
+ createToolCallMessage([
182
+ {
183
+ type: "toolCall",
184
+ id: "call_1",
185
+ name: "get_weather",
186
+ arguments: { location: "NYC" },
187
+ },
188
+ ]),
189
+ ],
190
+ [createTextMessage("The weather in NYC is Sunny")],
191
+ ]);
192
+ registerProvider(mockProvider);
193
+ const agent = new Agent({
194
+ model: testModel,
195
+ tools: [tool],
196
+ });
197
+ const events = [];
198
+ for await (const event of agent.run("What's the weather?")) {
199
+ events.push(event);
200
+ }
201
+ const toolStart = events.find((e) => e.type === "tool_execution_start");
202
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
203
+ expect(toolStart).toBeDefined();
204
+ expect(toolEnd).toBeDefined();
205
+ expect(toolStart.toolName).toBe("get_weather");
206
+ expect(toolEnd.result.content[0].text).toBe("Weather in NYC: Sunny");
207
+ expect(toolEnd.isError).toBe(false);
208
+ });
209
+ it("should execute multiple tools in parallel", async () => {
210
+ const tool1 = defineTool({
211
+ name: "get_weather",
212
+ description: "Get weather",
213
+ parameters: z.object({ location: z.string() }),
214
+ execute: async (args) => ({
215
+ content: [{ type: "text", text: `Weather: ${args.location}` }],
216
+ }),
217
+ });
218
+ const tool2 = defineTool({
219
+ name: "get_time",
220
+ description: "Get time",
221
+ parameters: z.object({ timezone: z.string() }),
222
+ execute: async (args) => ({
223
+ content: [{ type: "text", text: `Time: ${args.timezone}` }],
224
+ }),
225
+ });
226
+ const mockProvider = createMockProvider("test-provider", [
227
+ [
228
+ createToolCallMessage([
229
+ {
230
+ type: "toolCall",
231
+ id: "call_1",
232
+ name: "get_weather",
233
+ arguments: { location: "NYC" },
234
+ },
235
+ {
236
+ type: "toolCall",
237
+ id: "call_2",
238
+ name: "get_time",
239
+ arguments: { timezone: "EST" },
240
+ },
241
+ ]),
242
+ ],
243
+ [createTextMessage("Done")],
244
+ ]);
245
+ registerProvider(mockProvider);
246
+ const agent = new Agent({
247
+ model: testModel,
248
+ tools: [tool1, tool2],
249
+ toolExecution: "parallel",
250
+ });
251
+ const events = [];
252
+ for await (const event of agent.run("Get info")) {
253
+ events.push(event);
254
+ }
255
+ const toolStarts = events.filter((e) => e.type === "tool_execution_start");
256
+ const toolEnds = events.filter((e) => e.type === "tool_execution_end");
257
+ expect(toolStarts).toHaveLength(2);
258
+ expect(toolEnds).toHaveLength(2);
259
+ });
260
+ it("should handle tool execution errors", async () => {
261
+ const failingTool = defineTool({
262
+ name: "fail_tool",
263
+ description: "Always fails",
264
+ parameters: z.object({}),
265
+ execute: async () => {
266
+ throw new Error("Tool execution failed");
267
+ },
268
+ });
269
+ const mockProvider = createMockProvider("test-provider", [
270
+ [
271
+ createToolCallMessage([
272
+ {
273
+ type: "toolCall",
274
+ id: "call_1",
275
+ name: "fail_tool",
276
+ arguments: {},
277
+ },
278
+ ]),
279
+ ],
280
+ [createTextMessage("Sorry, the tool failed.")],
281
+ ]);
282
+ registerProvider(mockProvider);
283
+ const agent = new Agent({
284
+ model: testModel,
285
+ tools: [failingTool],
286
+ });
287
+ const events = [];
288
+ for await (const event of agent.run("Use failing tool")) {
289
+ events.push(event);
290
+ }
291
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
292
+ expect(toolEnd).toBeDefined();
293
+ expect(toolEnd.isError).toBe(true);
294
+ expect(toolEnd.result.content[0].text).toContain("Tool execution failed");
295
+ });
296
+ it("should handle unknown tool calls", async () => {
297
+ const mockProvider = createMockProvider("test-provider", [
298
+ [
299
+ createToolCallMessage([
300
+ {
301
+ type: "toolCall",
302
+ id: "call_1",
303
+ name: "unknown_tool",
304
+ arguments: {},
305
+ },
306
+ ]),
307
+ ],
308
+ [createTextMessage("I don't know that tool.")],
309
+ ]);
310
+ registerProvider(mockProvider);
311
+ const agent = new Agent({
312
+ model: testModel,
313
+ tools: [], // No tools registered
314
+ });
315
+ const events = [];
316
+ for await (const event of agent.run("Call unknown tool")) {
317
+ events.push(event);
318
+ }
319
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
320
+ expect(toolEnd).toBeDefined();
321
+ expect(toolEnd.isError).toBe(true);
322
+ expect(toolEnd.result.content[0].text).toContain('Unknown tool "unknown_tool"');
323
+ });
324
+ });
325
+ describe("Agent E2E - Advanced Features", () => {
326
+ it("should support beforeToolCall hook", async () => {
327
+ const tool = defineTool({
328
+ name: "sensitive_tool",
329
+ description: "Sensitive operation",
330
+ parameters: z.object({}),
331
+ execute: async () => ({
332
+ content: [{ type: "text", text: "Done" }],
333
+ }),
334
+ });
335
+ const mockProvider = createMockProvider("test-provider", [
336
+ [
337
+ createToolCallMessage([
338
+ {
339
+ type: "toolCall",
340
+ id: "call_1",
341
+ name: "sensitive_tool",
342
+ arguments: {},
343
+ },
344
+ ]),
345
+ ],
346
+ [createTextMessage("Blocked")],
347
+ ]);
348
+ registerProvider(mockProvider);
349
+ const beforeHook = vi.fn().mockResolvedValue({ block: true, reason: "Not allowed" });
350
+ const agent = new Agent({
351
+ model: testModel,
352
+ tools: [tool],
353
+ beforeToolCall: beforeHook,
354
+ });
355
+ for await (const _ of agent.run("Use sensitive tool")) {
356
+ // consume
357
+ }
358
+ expect(beforeHook).toHaveBeenCalled();
359
+ // The tool should have been blocked
360
+ const toolEnd = agent.messages.find((m) => m.role === "toolResult" && m.toolName === "sensitive_tool");
361
+ expect(toolEnd).toBeDefined();
362
+ if (toolEnd?.role === "toolResult") {
363
+ expect(toolEnd.isError).toBe(true);
364
+ }
365
+ });
366
+ it("should support afterToolCall hook", async () => {
367
+ const tool = defineTool({
368
+ name: "data_tool",
369
+ description: "Get data",
370
+ parameters: z.object({}),
371
+ execute: async () => ({
372
+ content: [{ type: "text", text: "Raw data" }],
373
+ }),
374
+ });
375
+ const mockProvider = createMockProvider("test-provider", [
376
+ [
377
+ createToolCallMessage([
378
+ {
379
+ type: "toolCall",
380
+ id: "call_1",
381
+ name: "data_tool",
382
+ arguments: {},
383
+ },
384
+ ]),
385
+ ],
386
+ [createTextMessage("Processed")],
387
+ ]);
388
+ registerProvider(mockProvider);
389
+ const afterHook = vi.fn().mockResolvedValue({
390
+ content: [{ type: "text", text: "Modified data" }],
391
+ });
392
+ const agent = new Agent({
393
+ model: testModel,
394
+ tools: [tool],
395
+ afterToolCall: afterHook,
396
+ });
397
+ for await (const _ of agent.run("Get data")) {
398
+ // consume
399
+ }
400
+ expect(afterHook).toHaveBeenCalled();
401
+ });
402
+ it("should support sequential tool execution", async () => {
403
+ const executionOrder = [];
404
+ const tool1 = defineTool({
405
+ name: "tool_a",
406
+ description: "Tool A",
407
+ parameters: z.object({}),
408
+ execute: async () => {
409
+ executionOrder.push("A");
410
+ await new Promise((resolve) => setTimeout(resolve, 50));
411
+ return { content: [{ type: "text", text: "A" }] };
412
+ },
413
+ });
414
+ const tool2 = defineTool({
415
+ name: "tool_b",
416
+ description: "Tool B",
417
+ parameters: z.object({}),
418
+ execute: async () => {
419
+ executionOrder.push("B");
420
+ return { content: [{ type: "text", text: "B" }] };
421
+ },
422
+ });
423
+ const mockProvider = createMockProvider("test-provider", [
424
+ [
425
+ createToolCallMessage([
426
+ {
427
+ type: "toolCall",
428
+ id: "call_1",
429
+ name: "tool_a",
430
+ arguments: {},
431
+ },
432
+ {
433
+ type: "toolCall",
434
+ id: "call_2",
435
+ name: "tool_b",
436
+ arguments: {},
437
+ },
438
+ ]),
439
+ ],
440
+ [createTextMessage("Done")],
441
+ ]);
442
+ registerProvider(mockProvider);
443
+ const agent = new Agent({
444
+ model: testModel,
445
+ tools: [tool1, tool2],
446
+ toolExecution: "sequential",
447
+ });
448
+ for await (const _ of agent.run("Sequential test")) {
449
+ // consume
450
+ }
451
+ // In sequential mode, B should execute after A completes
452
+ expect(executionOrder).toEqual(["A", "B"]);
453
+ });
454
+ it("should handle abort signal", async () => {
455
+ const mockProvider = createMockProvider("test-provider", [
456
+ [createTextMessage("Slow response")],
457
+ ]);
458
+ registerProvider(mockProvider);
459
+ const agent = new Agent({
460
+ model: testModel,
461
+ });
462
+ // Start the agent
463
+ const generator = agent.run("Test abort");
464
+ // Get first event
465
+ const firstEvent = await generator.next();
466
+ expect(firstEvent.done).toBe(false);
467
+ // Abort
468
+ agent.abort();
469
+ // Continue consuming - should finish
470
+ const remaining = [];
471
+ for await (const event of generator) {
472
+ remaining.push(event);
473
+ }
474
+ // Should have completed (not hang)
475
+ expect(agent.isStreaming).toBe(false);
476
+ });
477
+ it("should not hang when transformContext hook throws unexpectedly", async () => {
478
+ const mockProvider = createMockProvider("test-provider", [
479
+ [createTextMessage("Response")],
480
+ ]);
481
+ registerProvider(mockProvider);
482
+ const agent = new Agent({
483
+ model: testModel,
484
+ transformContext: () => {
485
+ throw new Error("Unexpected transformation failure");
486
+ },
487
+ });
488
+ const events = [];
489
+ for await (const event of agent.run("Test")) {
490
+ events.push(event);
491
+ }
492
+ // Must have completed without hanging
493
+ const agentEnd = events.find((e) => e.type === "agent_end");
494
+ expect(agentEnd).toBeDefined();
495
+ expect(agent.isStreaming).toBe(false);
496
+ });
497
+ it("should reset state correctly", async () => {
498
+ const mockProvider = createMockProvider("test-provider", [
499
+ [createTextMessage("Response")],
500
+ ]);
501
+ registerProvider(mockProvider);
502
+ const agent = new Agent({
503
+ model: testModel,
504
+ });
505
+ for await (const _ of agent.run("Test")) {
506
+ // consume
507
+ }
508
+ expect(agent.messages.length).toBeGreaterThan(0);
509
+ agent.reset();
510
+ expect(agent.messages).toEqual([]);
511
+ expect(agent.isStreaming).toBe(false);
512
+ expect(agent.streamingMessage).toBeUndefined();
513
+ expect(agent.pendingToolCalls.size).toBe(0);
514
+ });
515
+ it("should emit agent events to subscribers", async () => {
516
+ const mockProvider = createMockProvider("test-provider", [
517
+ [createTextMessage("Hello")],
518
+ ]);
519
+ registerProvider(mockProvider);
520
+ const agent = new Agent({
521
+ model: testModel,
522
+ });
523
+ const subscriberEvents = [];
524
+ const unsubscribe = agent.subscribe((event) => {
525
+ subscriberEvents.push(event);
526
+ });
527
+ for await (const _ of agent.run("Test")) {
528
+ // consume
529
+ }
530
+ expect(subscriberEvents.length).toBeGreaterThan(0);
531
+ expect(subscriberEvents.some((e) => e.type === "agent_start")).toBe(true);
532
+ expect(subscriberEvents.some((e) => e.type === "agent_end")).toBe(true);
533
+ unsubscribe();
534
+ });
535
+ it("should handle beforeToolCall hook that throws without hanging", async () => {
536
+ const tool = defineTool({
537
+ name: "normal_tool",
538
+ description: "A normal tool",
539
+ parameters: z.object({}),
540
+ execute: async () => ({
541
+ content: [{ type: "text", text: "Done" }],
542
+ }),
543
+ });
544
+ const mockProvider = createMockProvider("test-provider", [
545
+ [
546
+ createToolCallMessage([
547
+ {
548
+ type: "toolCall",
549
+ id: "call_1",
550
+ name: "normal_tool",
551
+ arguments: {},
552
+ },
553
+ ]),
554
+ ],
555
+ [createTextMessage("Recovered")],
556
+ ]);
557
+ registerProvider(mockProvider);
558
+ const agent = new Agent({
559
+ model: testModel,
560
+ tools: [tool],
561
+ beforeToolCall: () => {
562
+ throw new Error("Hook crash!");
563
+ },
564
+ });
565
+ const events = [];
566
+ for await (const event of agent.run("Test")) {
567
+ events.push(event);
568
+ }
569
+ // Must finish without hanging
570
+ const agentEnd = events.find((e) => e.type === "agent_end");
571
+ expect(agentEnd).toBeDefined();
572
+ // The tool should be marked as blocked with the hook error
573
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
574
+ expect(toolEnd).toBeDefined();
575
+ expect(toolEnd.isError).toBe(true);
576
+ });
577
+ it("should handle afterToolCall hook that throws without hanging", async () => {
578
+ const tool = defineTool({
579
+ name: "normal_tool",
580
+ description: "A normal tool",
581
+ parameters: z.object({}),
582
+ execute: async () => ({
583
+ content: [{ type: "text", text: "Done" }],
584
+ }),
585
+ });
586
+ const mockProvider = createMockProvider("test-provider", [
587
+ [
588
+ createToolCallMessage([
589
+ {
590
+ type: "toolCall",
591
+ id: "call_1",
592
+ name: "normal_tool",
593
+ arguments: {},
594
+ },
595
+ ]),
596
+ ],
597
+ [createTextMessage("Recovered")],
598
+ ]);
599
+ registerProvider(mockProvider);
600
+ const agent = new Agent({
601
+ model: testModel,
602
+ tools: [tool],
603
+ afterToolCall: () => {
604
+ throw new Error("Hook crash!");
605
+ },
606
+ });
607
+ const events = [];
608
+ for await (const event of agent.run("Test")) {
609
+ events.push(event);
610
+ }
611
+ // Must finish without hanging
612
+ const agentEnd = events.find((e) => e.type === "agent_end");
613
+ expect(agentEnd).toBeDefined();
614
+ // The tool result should still be the original (not overridden by failed hook)
615
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
616
+ expect(toolEnd).toBeDefined();
617
+ expect(toolEnd.isError).toBe(false);
618
+ expect(toolEnd.result.content[0].text).toBe("Done");
619
+ });
620
+ it("should isolate listener errors so one failing subscriber does not break others", async () => {
621
+ const mockProvider = createMockProvider("test-provider", [
622
+ [createTextMessage("Hello")],
623
+ ]);
624
+ registerProvider(mockProvider);
625
+ const agent = new Agent({
626
+ model: testModel,
627
+ });
628
+ const goodEvents = [];
629
+ const badListener = vi.fn().mockImplementation(() => {
630
+ throw new Error("Listener crash!");
631
+ });
632
+ const goodListener = vi.fn().mockImplementation((event) => {
633
+ goodEvents.push(event);
634
+ });
635
+ agent.subscribe(badListener);
636
+ agent.subscribe(goodListener);
637
+ for await (const _ of agent.run("Test")) {
638
+ // consume
639
+ }
640
+ // Bad listener should have been called and thrown
641
+ expect(badListener).toHaveBeenCalled();
642
+ // Good listener should still have received events despite bad listener crashing
643
+ expect(goodListener).toHaveBeenCalled();
644
+ expect(goodEvents.some((e) => e.type === "agent_start")).toBe(true);
645
+ expect(goodEvents.some((e) => e.type === "agent_end")).toBe(true);
646
+ });
647
+ it("should emit tool_execution_update events to generator consumers", async () => {
648
+ const updatingTool = defineTool({
649
+ name: "progress_tool",
650
+ description: "Reports progress via onUpdate",
651
+ parameters: z.object({}),
652
+ execute: async (_args, { onUpdate }) => {
653
+ if (onUpdate) {
654
+ onUpdate({ content: [{ type: "text", text: "25%" }] });
655
+ onUpdate({ content: [{ type: "text", text: "50%" }] });
656
+ onUpdate({ content: [{ type: "text", text: "75%" }] });
657
+ }
658
+ return { content: [{ type: "text", text: "100%" }] };
659
+ },
660
+ });
661
+ const mockProvider = createMockProvider("test-provider", [
662
+ [
663
+ createToolCallMessage([
664
+ {
665
+ type: "toolCall",
666
+ id: "call_1",
667
+ name: "progress_tool",
668
+ arguments: {},
669
+ },
670
+ ]),
671
+ ],
672
+ [createTextMessage("All done")],
673
+ ]);
674
+ registerProvider(mockProvider);
675
+ const agent = new Agent({
676
+ model: testModel,
677
+ tools: [updatingTool],
678
+ });
679
+ const events = [];
680
+ for await (const event of agent.run("Run progress tool")) {
681
+ events.push(event);
682
+ }
683
+ const updateEvents = events.filter((e) => e.type === "tool_execution_update");
684
+ expect(updateEvents.length).toBe(3);
685
+ expect(updateEvents[0].partialResult.content[0].text).toBe("25%");
686
+ expect(updateEvents[1].partialResult.content[0].text).toBe("50%");
687
+ expect(updateEvents[2].partialResult.content[0].text).toBe("75%");
688
+ const agentEnd = events.find((e) => e.type === "agent_end");
689
+ expect(agentEnd).toBeDefined();
690
+ });
691
+ it("should also emit tool_execution_update events to subscriber listeners", async () => {
692
+ const updatingTool = defineTool({
693
+ name: "progress_tool",
694
+ description: "Reports progress via onUpdate",
695
+ parameters: z.object({}),
696
+ execute: async (_args, { onUpdate }) => {
697
+ if (onUpdate) {
698
+ onUpdate({ content: [{ type: "text", text: "step 1" }] });
699
+ }
700
+ return { content: [{ type: "text", text: "done" }] };
701
+ },
702
+ });
703
+ const mockProvider = createMockProvider("test-provider", [
704
+ [
705
+ createToolCallMessage([
706
+ {
707
+ type: "toolCall",
708
+ id: "call_1",
709
+ name: "progress_tool",
710
+ arguments: {},
711
+ },
712
+ ]),
713
+ ],
714
+ [createTextMessage("All done")],
715
+ ]);
716
+ registerProvider(mockProvider);
717
+ const agent = new Agent({
718
+ model: testModel,
719
+ tools: [updatingTool],
720
+ });
721
+ const subscriberUpdates = [];
722
+ agent.subscribe((event) => {
723
+ if (event.type === "tool_execution_update") {
724
+ subscriberUpdates.push(event);
725
+ }
726
+ });
727
+ for await (const _ of agent.run("Run progress tool")) {
728
+ // consume
729
+ }
730
+ expect(subscriberUpdates.length).toBe(1);
731
+ expect(subscriberUpdates[0].partialResult.content[0].text).toBe("step 1");
732
+ });
733
+ it("should support transformContext hook", async () => {
734
+ const mockProvider = createMockProvider("test-provider", [
735
+ [createTextMessage("Transformed")],
736
+ ]);
737
+ registerProvider(mockProvider);
738
+ const transformFn = vi.fn().mockImplementation((messages) => {
739
+ return [
740
+ ...messages,
741
+ {
742
+ role: "user",
743
+ content: "[Transformed]",
744
+ timestamp: Date.now(),
745
+ },
746
+ ];
747
+ });
748
+ const agent = new Agent({
749
+ model: testModel,
750
+ transformContext: transformFn,
751
+ });
752
+ for await (const _ of agent.run("Test")) {
753
+ // consume
754
+ }
755
+ expect(transformFn).toHaveBeenCalled();
756
+ });
757
+ });
758
+ describe("Agent E2E - Complex Scenarios", () => {
759
+ it("should handle multi-turn conversation with tools", async () => {
760
+ const calculator = defineTool({
761
+ name: "calculate",
762
+ description: "Calculate",
763
+ parameters: z.object({ expression: z.string() }),
764
+ execute: async (args) => ({
765
+ content: [{ type: "text", text: `Result: ${args.expression}` }],
766
+ }),
767
+ });
768
+ const mockProvider = createMockProvider("test-provider", [
769
+ [
770
+ createToolCallMessage([
771
+ {
772
+ type: "toolCall",
773
+ id: "call_1",
774
+ name: "calculate",
775
+ arguments: { expression: "2+2" },
776
+ },
777
+ ]),
778
+ ],
779
+ [createTextMessage("The result is 4")],
780
+ ]);
781
+ registerProvider(mockProvider);
782
+ const agent = new Agent({
783
+ model: testModel,
784
+ tools: [calculator],
785
+ });
786
+ const events = [];
787
+ for await (const event of agent.run("Calculate 2+2")) {
788
+ events.push(event);
789
+ }
790
+ // Should have tool execution events
791
+ const toolEvents = events.filter((e) => e.type === "tool_execution_start" || e.type === "tool_execution_end");
792
+ expect(toolEvents.length).toBe(2);
793
+ // Final message should be the assistant's response
794
+ const finalMessages = agent.messages;
795
+ const assistantMessages = finalMessages.filter((m) => m.role === "assistant");
796
+ expect(assistantMessages.length).toBeGreaterThan(0);
797
+ });
798
+ it("should handle empty tool calls gracefully", async () => {
799
+ const mockProvider = createMockProvider("test-provider", [
800
+ [createToolCallMessage([])], // Empty tool calls
801
+ [createTextMessage("No tools needed")],
802
+ ]);
803
+ registerProvider(mockProvider);
804
+ const agent = new Agent({
805
+ model: testModel,
806
+ });
807
+ const events = [];
808
+ for await (const event of agent.run("Test")) {
809
+ events.push(event);
810
+ }
811
+ // Should complete without errors
812
+ const agentEnd = events.find((e) => e.type === "agent_end");
813
+ expect(agentEnd).toBeDefined();
814
+ });
815
+ it("should validate tool arguments with Zod schema", async () => {
816
+ const tool = defineTool({
817
+ name: "strict_tool",
818
+ description: "Requires specific args",
819
+ parameters: z.object({
820
+ count: z.number().min(1).max(10),
821
+ name: z.string().min(1),
822
+ }),
823
+ execute: async (args) => ({
824
+ content: [{ type: "text", text: `Count: ${args.count}, Name: ${args.name}` }],
825
+ }),
826
+ });
827
+ const mockProvider = createMockProvider("test-provider", [
828
+ [
829
+ createToolCallMessage([
830
+ {
831
+ type: "toolCall",
832
+ id: "call_1",
833
+ name: "strict_tool",
834
+ arguments: { count: 5, name: "test" },
835
+ },
836
+ ]),
837
+ ],
838
+ [createTextMessage("Done")],
839
+ ]);
840
+ registerProvider(mockProvider);
841
+ const agent = new Agent({
842
+ model: testModel,
843
+ tools: [tool],
844
+ });
845
+ const events = [];
846
+ for await (const event of agent.run("Use strict tool")) {
847
+ events.push(event);
848
+ }
849
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
850
+ expect(toolEnd).toBeDefined();
851
+ expect(toolEnd.isError).toBe(false);
852
+ });
853
+ });
854
+ //# sourceMappingURL=e2e.test.js.map