@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.
- package/README.md +241 -0
- package/dist/__tests__/agent-features.test.d.ts +2 -0
- package/dist/__tests__/agent-features.test.d.ts.map +1 -0
- package/dist/__tests__/agent-features.test.js +108 -0
- package/dist/__tests__/agent-features.test.js.map +1 -0
- package/dist/__tests__/agent.test.d.ts +2 -0
- package/dist/__tests__/agent.test.d.ts.map +1 -0
- package/dist/__tests__/agent.test.js +109 -0
- package/dist/__tests__/agent.test.js.map +1 -0
- package/dist/__tests__/e2e.test.d.ts +2 -0
- package/dist/__tests__/e2e.test.d.ts.map +1 -0
- package/dist/__tests__/e2e.test.js +854 -0
- package/dist/__tests__/e2e.test.js.map +1 -0
- package/dist/agent.d.ts +64 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +508 -0
- package/dist/agent.js.map +1 -0
- package/dist/define-tool.d.ts +17 -0
- package/dist/define-tool.d.ts.map +1 -0
- package/dist/define-tool.js +37 -0
- package/dist/define-tool.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +104 -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 +33 -0
|
@@ -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
|