@funkai/agents 0.1.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/.generated/req.txt +1 -0
- package/.turbo/turbo-build.log +21 -0
- package/.turbo/turbo-test$colon$coverage.log +109 -0
- package/.turbo/turbo-test.log +141 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +16 -0
- package/ISSUES.md +540 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/banner.svg +97 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
- package/coverage/lcov-report/core/agents/base/index.html +146 -0
- package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
- package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
- package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
- package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
- package/coverage/lcov-report/core/agents/flow/index.html +146 -0
- package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
- package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
- package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
- package/coverage/lcov-report/core/index.html +131 -0
- package/coverage/lcov-report/core/logger.ts.html +541 -0
- package/coverage/lcov-report/core/models/providers/index.html +116 -0
- package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
- package/coverage/lcov-report/core/provider/index.html +131 -0
- package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
- package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
- package/coverage/lcov-report/core/tool.ts.html +577 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +221 -0
- package/coverage/lcov-report/lib/hooks.ts.html +262 -0
- package/coverage/lcov-report/lib/index.html +161 -0
- package/coverage/lcov-report/lib/middleware.ts.html +274 -0
- package/coverage/lcov-report/lib/runnable.ts.html +151 -0
- package/coverage/lcov-report/lib/trace.ts.html +520 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/utils/attempt.ts.html +199 -0
- package/coverage/lcov-report/utils/error.ts.html +421 -0
- package/coverage/lcov-report/utils/index.html +176 -0
- package/coverage/lcov-report/utils/resolve.ts.html +208 -0
- package/coverage/lcov-report/utils/result.ts.html +538 -0
- package/coverage/lcov-report/utils/zod.ts.html +178 -0
- package/coverage/lcov.info +1566 -0
- package/dist/index.d.mts +2883 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2312 -0
- package/dist/index.mjs.map +1 -0
- package/docs/core/agent.md +231 -0
- package/docs/core/hooks.md +95 -0
- package/docs/core/overview.md +87 -0
- package/docs/core/step.md +279 -0
- package/docs/core/tools.md +98 -0
- package/docs/core/workflow.md +235 -0
- package/docs/guides/create-agent.md +224 -0
- package/docs/guides/create-tool.md +137 -0
- package/docs/guides/create-workflow.md +374 -0
- package/docs/overview.md +244 -0
- package/docs/provider/models.md +55 -0
- package/docs/provider/overview.md +106 -0
- package/docs/provider/usage.md +100 -0
- package/docs/research/experimental-context.md +167 -0
- package/docs/research/gap-analysis.md +86 -0
- package/docs/research/prepare-step-and-active-tools.md +138 -0
- package/docs/research/sub-agent-model.md +249 -0
- package/docs/troubleshooting.md +60 -0
- package/logo.svg +17 -0
- package/models.config.json +18 -0
- package/package.json +60 -0
- package/scripts/generate-models.ts +324 -0
- package/src/core/agents/base/agent.test.ts +1522 -0
- package/src/core/agents/base/agent.ts +547 -0
- package/src/core/agents/base/output.test.ts +93 -0
- package/src/core/agents/base/output.ts +57 -0
- package/src/core/agents/base/types.test-d.ts +69 -0
- package/src/core/agents/base/types.ts +503 -0
- package/src/core/agents/base/utils.test.ts +397 -0
- package/src/core/agents/base/utils.ts +197 -0
- package/src/core/agents/flow/engine.test.ts +452 -0
- package/src/core/agents/flow/engine.ts +281 -0
- package/src/core/agents/flow/flow-agent.test.ts +1027 -0
- package/src/core/agents/flow/flow-agent.ts +473 -0
- package/src/core/agents/flow/messages.test.ts +198 -0
- package/src/core/agents/flow/messages.ts +141 -0
- package/src/core/agents/flow/steps/agent.test.ts +280 -0
- package/src/core/agents/flow/steps/agent.ts +87 -0
- package/src/core/agents/flow/steps/all.test.ts +300 -0
- package/src/core/agents/flow/steps/all.ts +73 -0
- package/src/core/agents/flow/steps/builder.ts +124 -0
- package/src/core/agents/flow/steps/each.test.ts +257 -0
- package/src/core/agents/flow/steps/each.ts +61 -0
- package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
- package/src/core/agents/flow/steps/factory.test.ts +1025 -0
- package/src/core/agents/flow/steps/factory.ts +645 -0
- package/src/core/agents/flow/steps/map.test.ts +273 -0
- package/src/core/agents/flow/steps/map.ts +75 -0
- package/src/core/agents/flow/steps/race.test.ts +290 -0
- package/src/core/agents/flow/steps/race.ts +59 -0
- package/src/core/agents/flow/steps/reduce.test.ts +310 -0
- package/src/core/agents/flow/steps/reduce.ts +73 -0
- package/src/core/agents/flow/steps/result.ts +27 -0
- package/src/core/agents/flow/steps/step.test.ts +402 -0
- package/src/core/agents/flow/steps/step.ts +51 -0
- package/src/core/agents/flow/steps/while.test.ts +283 -0
- package/src/core/agents/flow/steps/while.ts +75 -0
- package/src/core/agents/flow/types.ts +348 -0
- package/src/core/logger.test.ts +163 -0
- package/src/core/logger.ts +152 -0
- package/src/core/models/index.test.ts +137 -0
- package/src/core/models/index.ts +152 -0
- package/src/core/models/providers/openai.ts +84 -0
- package/src/core/provider/provider.test.ts +128 -0
- package/src/core/provider/provider.ts +99 -0
- package/src/core/provider/types.ts +98 -0
- package/src/core/provider/usage.test.ts +304 -0
- package/src/core/provider/usage.ts +97 -0
- package/src/core/tool.test.ts +65 -0
- package/src/core/tool.ts +164 -0
- package/src/core/types.ts +66 -0
- package/src/index.ts +95 -0
- package/src/lib/context.test.ts +86 -0
- package/src/lib/context.ts +49 -0
- package/src/lib/hooks.test.ts +102 -0
- package/src/lib/hooks.ts +59 -0
- package/src/lib/middleware.test.ts +122 -0
- package/src/lib/middleware.ts +63 -0
- package/src/lib/runnable.test.ts +41 -0
- package/src/lib/runnable.ts +22 -0
- package/src/lib/trace.test.ts +291 -0
- package/src/lib/trace.ts +145 -0
- package/src/models/index.ts +123 -0
- package/src/models/providers/index.ts +15 -0
- package/src/models/providers/openai.ts +84 -0
- package/src/testing/context.ts +32 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/logger.ts +19 -0
- package/src/utils/attempt.test.ts +127 -0
- package/src/utils/attempt.ts +38 -0
- package/src/utils/error.test.ts +179 -0
- package/src/utils/error.ts +112 -0
- package/src/utils/resolve.test.ts +38 -0
- package/src/utils/resolve.ts +41 -0
- package/src/utils/result.test.ts +79 -0
- package/src/utils/result.ts +151 -0
- package/src/utils/zod.test.ts +69 -0
- package/src/utils/zod.ts +31 -0
- package/tsconfig.json +25 -0
- package/tsdown.config.ts +15 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { agent } from "@/core/agents/base/agent.js";
|
|
5
|
+
import { RUNNABLE_META, type RunnableMeta } from "@/lib/runnable.js";
|
|
6
|
+
import { createMockLogger } from "@/testing/index.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Mocks
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const mockGenerateText = vi.fn();
|
|
13
|
+
const mockStreamText = vi.fn();
|
|
14
|
+
const mockStepCountIs = vi.fn<(n: number) => string>().mockReturnValue("mock-stop-condition");
|
|
15
|
+
|
|
16
|
+
vi.mock("ai", () => ({
|
|
17
|
+
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
|
18
|
+
streamText: (...args: unknown[]) => mockStreamText(...args),
|
|
19
|
+
stepCountIs: (n: number) => mockStepCountIs(n),
|
|
20
|
+
Output: {
|
|
21
|
+
text: () => ({ parseCompleteOutput: vi.fn() }),
|
|
22
|
+
object: ({ schema }: { schema: unknown }) => ({ parseCompleteOutput: vi.fn(), schema }),
|
|
23
|
+
array: ({ element }: { element: unknown }) => ({ parseCompleteOutput: vi.fn(), element }),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@/lib/middleware.js", () => ({
|
|
28
|
+
withModelMiddleware: vi.fn(async ({ model }: { model: unknown }) => model),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@/core/provider/provider.js", () => ({
|
|
32
|
+
openrouter: vi.fn(() => ({ modelId: "mock-model" })),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const MOCK_TOTAL_USAGE = {
|
|
40
|
+
inputTokens: 100,
|
|
41
|
+
outputTokens: 50,
|
|
42
|
+
totalTokens: 150,
|
|
43
|
+
inputTokenDetails: {
|
|
44
|
+
noCacheTokens: 85,
|
|
45
|
+
cacheReadTokens: 10,
|
|
46
|
+
cacheWriteTokens: 5,
|
|
47
|
+
},
|
|
48
|
+
outputTokenDetails: {
|
|
49
|
+
textTokens: 47,
|
|
50
|
+
reasoningTokens: 3,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function createMockGenerateResult(overrides?: {
|
|
55
|
+
text?: string;
|
|
56
|
+
output?: unknown;
|
|
57
|
+
response?: { messages: unknown[] };
|
|
58
|
+
totalUsage?: typeof MOCK_TOTAL_USAGE;
|
|
59
|
+
finishReason?: string;
|
|
60
|
+
}) {
|
|
61
|
+
const defaults = {
|
|
62
|
+
text: "mock response text",
|
|
63
|
+
output: undefined,
|
|
64
|
+
response: { messages: [{ role: "assistant", content: "mock" }] },
|
|
65
|
+
totalUsage: MOCK_TOTAL_USAGE,
|
|
66
|
+
finishReason: "stop",
|
|
67
|
+
};
|
|
68
|
+
return { ...defaults, ...overrides };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createMockStreamResult(overrides?: {
|
|
72
|
+
text?: string;
|
|
73
|
+
output?: unknown;
|
|
74
|
+
response?: { messages: unknown[] };
|
|
75
|
+
chunks?: string[];
|
|
76
|
+
totalUsage?: typeof MOCK_TOTAL_USAGE;
|
|
77
|
+
finishReason?: string;
|
|
78
|
+
}) {
|
|
79
|
+
const defaults = {
|
|
80
|
+
chunks: ["hello", " world"] as string[],
|
|
81
|
+
output: undefined as unknown,
|
|
82
|
+
response: undefined as { messages: unknown[] } | undefined,
|
|
83
|
+
totalUsage: MOCK_TOTAL_USAGE,
|
|
84
|
+
finishReason: "stop",
|
|
85
|
+
};
|
|
86
|
+
const merged = { ...defaults, ...overrides };
|
|
87
|
+
const chunks = merged.chunks;
|
|
88
|
+
const textValue = merged.text ?? chunks.join("");
|
|
89
|
+
|
|
90
|
+
async function* makeFullStream() {
|
|
91
|
+
for (const chunk of chunks) {
|
|
92
|
+
yield { type: "text-delta" as const, textDelta: chunk };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
fullStream: makeFullStream(),
|
|
98
|
+
text: Promise.resolve(textValue),
|
|
99
|
+
output: Promise.resolve(merged.output),
|
|
100
|
+
response: Promise.resolve(
|
|
101
|
+
merged.response ?? { messages: [{ role: "assistant", content: textValue }] },
|
|
102
|
+
),
|
|
103
|
+
totalUsage: Promise.resolve(merged.totalUsage),
|
|
104
|
+
finishReason: Promise.resolve(merged.finishReason),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createSimpleAgent(overrides?: Partial<Parameters<typeof agent>[0]>) {
|
|
109
|
+
return agent({
|
|
110
|
+
name: "test-agent",
|
|
111
|
+
model: { modelId: "mock-model" } as never,
|
|
112
|
+
system: "You are a test agent.",
|
|
113
|
+
logger: createMockLogger(),
|
|
114
|
+
...overrides,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createTypedAgent(
|
|
119
|
+
overrides?: Partial<Parameters<typeof agent<{ topic: string }, string>>[0]>,
|
|
120
|
+
) {
|
|
121
|
+
return agent<{ topic: string }, string>({
|
|
122
|
+
name: "typed-agent",
|
|
123
|
+
model: { modelId: "mock-model" } as never,
|
|
124
|
+
input: z.object({ topic: z.string() }),
|
|
125
|
+
prompt: ({ input }) => `Tell me about ${input.topic}`,
|
|
126
|
+
system: "You are a typed agent.",
|
|
127
|
+
logger: createMockLogger(),
|
|
128
|
+
...overrides,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Setup
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
vi.clearAllMocks();
|
|
138
|
+
mockGenerateText.mockResolvedValue(createMockGenerateResult());
|
|
139
|
+
mockStreamText.mockReturnValue(createMockStreamResult());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Agent creation
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe("agent creation", () => {
|
|
147
|
+
it("returns an object with generate, stream, and fn methods", () => {
|
|
148
|
+
const a = createSimpleAgent();
|
|
149
|
+
|
|
150
|
+
expect(typeof a.generate).toBe("function");
|
|
151
|
+
expect(typeof a.stream).toBe("function");
|
|
152
|
+
expect(typeof a.fn).toBe("function");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("attaches RUNNABLE_META with name and no inputSchema for simple agents", () => {
|
|
156
|
+
const a = createSimpleAgent();
|
|
157
|
+
// eslint-disable-next-line security/detect-object-injection -- Symbol-keyed property access; symbols cannot be user-controlled
|
|
158
|
+
const meta = (a as unknown as Record<symbol, unknown>)[RUNNABLE_META] as RunnableMeta;
|
|
159
|
+
|
|
160
|
+
expect(meta.name).toBe("test-agent");
|
|
161
|
+
expect(meta.inputSchema).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("attaches RUNNABLE_META with name and inputSchema for typed agents", () => {
|
|
165
|
+
const a = createTypedAgent();
|
|
166
|
+
// eslint-disable-next-line security/detect-object-injection -- Symbol-keyed property access; symbols cannot be user-controlled
|
|
167
|
+
const meta = (a as unknown as Record<symbol, unknown>)[RUNNABLE_META] as RunnableMeta;
|
|
168
|
+
|
|
169
|
+
expect(meta.name).toBe("typed-agent");
|
|
170
|
+
expect(meta.inputSchema).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// generate() — success path
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe("generate() success", () => {
|
|
179
|
+
it("returns ok: true with text output for simple agent", async () => {
|
|
180
|
+
const a = createSimpleAgent();
|
|
181
|
+
const result = await a.generate("hello");
|
|
182
|
+
|
|
183
|
+
expect(result.ok).toBe(true);
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
expect(result.output).toBe("mock response text");
|
|
188
|
+
expect(result.messages).toBeInstanceOf(Array);
|
|
189
|
+
expect(result.usage).toEqual({
|
|
190
|
+
inputTokens: 100,
|
|
191
|
+
outputTokens: 50,
|
|
192
|
+
totalTokens: 150,
|
|
193
|
+
cacheReadTokens: 10,
|
|
194
|
+
cacheWriteTokens: 5,
|
|
195
|
+
reasoningTokens: 3,
|
|
196
|
+
});
|
|
197
|
+
expect(result.finishReason).toBe("stop");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns ok: true with text output for typed agent", async () => {
|
|
201
|
+
const a = createTypedAgent();
|
|
202
|
+
const result = await a.generate({ topic: "TypeScript" });
|
|
203
|
+
|
|
204
|
+
expect(result.ok).toBe(true);
|
|
205
|
+
if (!result.ok) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
expect(result.output).toBe("mock response text");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("passes system prompt to generateText", async () => {
|
|
212
|
+
const a = createSimpleAgent({ system: "Custom system prompt" });
|
|
213
|
+
await a.generate("test");
|
|
214
|
+
|
|
215
|
+
expect(mockGenerateText).toHaveBeenCalledTimes(1);
|
|
216
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
217
|
+
if (!callArgs) {
|
|
218
|
+
throw new Error("Expected generateText to be called");
|
|
219
|
+
}
|
|
220
|
+
expect(callArgs[0].system).toBe("Custom system prompt");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("passes string prompt for simple mode", async () => {
|
|
224
|
+
const a = createSimpleAgent();
|
|
225
|
+
await a.generate("hello world");
|
|
226
|
+
|
|
227
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
228
|
+
if (!callArgs) {
|
|
229
|
+
throw new Error("Expected generateText to be called");
|
|
230
|
+
}
|
|
231
|
+
expect(callArgs[0].prompt).toBe("hello world");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("passes rendered prompt for typed mode", async () => {
|
|
235
|
+
const a = createTypedAgent();
|
|
236
|
+
await a.generate({ topic: "Rust" });
|
|
237
|
+
|
|
238
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
239
|
+
if (!callArgs) {
|
|
240
|
+
throw new Error("Expected generateText to be called");
|
|
241
|
+
}
|
|
242
|
+
expect(callArgs[0].prompt).toBe("Tell me about Rust");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("passes messages array for message-based input", async () => {
|
|
246
|
+
const a = createSimpleAgent();
|
|
247
|
+
const messages = [{ role: "user" as const, content: "hello" }];
|
|
248
|
+
await a.generate(messages as never);
|
|
249
|
+
|
|
250
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
251
|
+
if (!callArgs) {
|
|
252
|
+
throw new Error("Expected generateText to be called");
|
|
253
|
+
}
|
|
254
|
+
expect(callArgs[0].messages).toEqual(messages);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("uses default maxSteps of 20 when not specified", async () => {
|
|
258
|
+
const a = createSimpleAgent();
|
|
259
|
+
await a.generate("test");
|
|
260
|
+
|
|
261
|
+
expect(mockStepCountIs).toHaveBeenCalledWith(20);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("uses custom maxSteps from config", async () => {
|
|
265
|
+
const a = createSimpleAgent({ maxSteps: 10 });
|
|
266
|
+
await a.generate("test");
|
|
267
|
+
|
|
268
|
+
expect(mockStepCountIs).toHaveBeenCalledWith(10);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("uses maxSteps from overrides over config", async () => {
|
|
272
|
+
const a = createSimpleAgent({ maxSteps: 10 });
|
|
273
|
+
await a.generate("test", { maxSteps: 5 });
|
|
274
|
+
|
|
275
|
+
expect(mockStepCountIs).toHaveBeenCalledWith(5);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// generate() — input validation
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
describe("generate() input validation", () => {
|
|
284
|
+
it("returns VALIDATION_ERROR when typed input fails safeParse", async () => {
|
|
285
|
+
const a = createTypedAgent();
|
|
286
|
+
|
|
287
|
+
// @ts-expect-error - intentionally invalid input
|
|
288
|
+
const result = await a.generate({ topic: 123 });
|
|
289
|
+
|
|
290
|
+
expect(result.ok).toBe(false);
|
|
291
|
+
if (result.ok) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
295
|
+
expect(result.error.message).toContain("Input validation failed");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns VALIDATION_ERROR when required fields are missing", async () => {
|
|
299
|
+
const a = createTypedAgent();
|
|
300
|
+
|
|
301
|
+
// @ts-expect-error - intentionally missing field
|
|
302
|
+
const result = await a.generate({});
|
|
303
|
+
|
|
304
|
+
expect(result.ok).toBe(false);
|
|
305
|
+
if (result.ok) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("does not call generateText when input validation fails", async () => {
|
|
312
|
+
const a = createTypedAgent();
|
|
313
|
+
|
|
314
|
+
// @ts-expect-error - intentionally invalid input
|
|
315
|
+
await a.generate({ topic: 123 });
|
|
316
|
+
|
|
317
|
+
expect(mockGenerateText).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("skips validation for simple agents without input schema", async () => {
|
|
321
|
+
const a = createSimpleAgent();
|
|
322
|
+
const result = await a.generate("anything");
|
|
323
|
+
|
|
324
|
+
expect(result.ok).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// generate() — output resolution
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
describe("generate() output resolution", () => {
|
|
333
|
+
it("returns text output when no output config is set", async () => {
|
|
334
|
+
mockGenerateText.mockResolvedValue(createMockGenerateResult({ text: "text output" }));
|
|
335
|
+
|
|
336
|
+
const a = createSimpleAgent();
|
|
337
|
+
const result = await a.generate("test");
|
|
338
|
+
|
|
339
|
+
expect(result.ok).toBe(true);
|
|
340
|
+
if (!result.ok) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
expect(result.output).toBe("text output");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("returns structured output when output config produces an output field", async () => {
|
|
347
|
+
mockGenerateText.mockResolvedValue(
|
|
348
|
+
createMockGenerateResult({ output: { summary: "structured" } }),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const a = createSimpleAgent({
|
|
352
|
+
output: { parseCompleteOutput: vi.fn() } as never,
|
|
353
|
+
});
|
|
354
|
+
const result = await a.generate("test");
|
|
355
|
+
|
|
356
|
+
expect(result.ok).toBe(true);
|
|
357
|
+
if (!result.ok) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
expect(result.output).toEqual({ summary: "structured" });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// generate() — system prompt resolution
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
describe("generate() system prompt", () => {
|
|
369
|
+
it("passes static string system prompt", async () => {
|
|
370
|
+
const a = createSimpleAgent({ system: "Static system" });
|
|
371
|
+
await a.generate("test");
|
|
372
|
+
|
|
373
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
374
|
+
if (!callArgs) {
|
|
375
|
+
throw new Error("Expected generateText to be called");
|
|
376
|
+
}
|
|
377
|
+
expect(callArgs[0].system).toBe("Static system");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("passes function system prompt resolved with input", async () => {
|
|
381
|
+
const a = createSimpleAgent({
|
|
382
|
+
system: ({ input }: { input: unknown }) => `System for: ${input}`,
|
|
383
|
+
});
|
|
384
|
+
await a.generate("my-input");
|
|
385
|
+
|
|
386
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
387
|
+
if (!callArgs) {
|
|
388
|
+
throw new Error("Expected generateText to be called");
|
|
389
|
+
}
|
|
390
|
+
expect(callArgs[0].system).toBe("System for: my-input");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("passes undefined system when not configured", async () => {
|
|
394
|
+
const a = createSimpleAgent({ system: undefined });
|
|
395
|
+
await a.generate("test");
|
|
396
|
+
|
|
397
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
398
|
+
if (!callArgs) {
|
|
399
|
+
throw new Error("Expected generateText to be called");
|
|
400
|
+
}
|
|
401
|
+
expect(callArgs[0].system).toBeUndefined();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("uses override system prompt over config", async () => {
|
|
405
|
+
const a = createSimpleAgent({ system: "original" });
|
|
406
|
+
await a.generate("test", { system: "overridden" });
|
|
407
|
+
|
|
408
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
409
|
+
if (!callArgs) {
|
|
410
|
+
throw new Error("Expected generateText to be called");
|
|
411
|
+
}
|
|
412
|
+
expect(callArgs[0].system).toBe("overridden");
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// generate() — hooks
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe("generate() hooks", () => {
|
|
421
|
+
it("fires onStart hook with input", async () => {
|
|
422
|
+
const onStart = vi.fn();
|
|
423
|
+
const a = createSimpleAgent({ onStart });
|
|
424
|
+
await a.generate("hello");
|
|
425
|
+
|
|
426
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
427
|
+
const firstCall = onStart.mock.calls[0];
|
|
428
|
+
if (!firstCall) {
|
|
429
|
+
throw new Error("Expected onStart first call");
|
|
430
|
+
}
|
|
431
|
+
expect(firstCall[0]).toEqual({ input: "hello" });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("fires onFinish hook with input, result (including usage), and duration", async () => {
|
|
435
|
+
const onFinish = vi.fn();
|
|
436
|
+
const a = createSimpleAgent({ onFinish });
|
|
437
|
+
await a.generate("hello");
|
|
438
|
+
|
|
439
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
440
|
+
const firstCall = onFinish.mock.calls[0];
|
|
441
|
+
if (!firstCall) {
|
|
442
|
+
throw new Error("Expected onFinish first call");
|
|
443
|
+
}
|
|
444
|
+
const event = firstCall[0];
|
|
445
|
+
expect(event.input).toBe("hello");
|
|
446
|
+
expect(event.result).toHaveProperty("output");
|
|
447
|
+
expect(event.result).toHaveProperty("messages");
|
|
448
|
+
expect(event.result).toHaveProperty("usage");
|
|
449
|
+
expect(event.result).toHaveProperty("finishReason");
|
|
450
|
+
expect(event.duration).toBeGreaterThanOrEqual(0);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("fires onStepFinish hook during tool loop", async () => {
|
|
454
|
+
const onStepFinish = vi.fn();
|
|
455
|
+
|
|
456
|
+
mockGenerateText.mockImplementation(
|
|
457
|
+
async (opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
458
|
+
if (opts.onStepFinish) {
|
|
459
|
+
await opts.onStepFinish({
|
|
460
|
+
toolCalls: [],
|
|
461
|
+
toolResults: [],
|
|
462
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
463
|
+
});
|
|
464
|
+
await opts.onStepFinish({
|
|
465
|
+
toolCalls: [],
|
|
466
|
+
toolResults: [],
|
|
467
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return createMockGenerateResult();
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
475
|
+
await a.generate("test");
|
|
476
|
+
|
|
477
|
+
expect(onStepFinish).toHaveBeenCalledTimes(2);
|
|
478
|
+
const firstCall = onStepFinish.mock.calls[0];
|
|
479
|
+
if (!firstCall) {
|
|
480
|
+
throw new Error("Expected onStepFinish first call");
|
|
481
|
+
}
|
|
482
|
+
expect(firstCall[0].stepId).toBe("test-agent:0");
|
|
483
|
+
|
|
484
|
+
const secondCall = onStepFinish.mock.calls[1];
|
|
485
|
+
if (!secondCall) {
|
|
486
|
+
throw new Error("Expected onStepFinish second call");
|
|
487
|
+
}
|
|
488
|
+
expect(secondCall[0].stepId).toBe("test-agent:1");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("fires onStepFinish with mapped toolCalls and toolResults in generate", async () => {
|
|
492
|
+
const onStepFinish = vi.fn();
|
|
493
|
+
|
|
494
|
+
mockGenerateText.mockImplementation(
|
|
495
|
+
async (opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
496
|
+
if (opts.onStepFinish) {
|
|
497
|
+
await opts.onStepFinish({
|
|
498
|
+
toolCalls: [{ toolName: "myTool", args: { foo: "bar" } }],
|
|
499
|
+
toolResults: [{ toolName: "myTool", result: { answer: 42 } }],
|
|
500
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
return createMockGenerateResult();
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
508
|
+
await a.generate("test");
|
|
509
|
+
|
|
510
|
+
expect(onStepFinish).toHaveBeenCalledTimes(1);
|
|
511
|
+
const firstCall = onStepFinish.mock.calls[0];
|
|
512
|
+
if (!firstCall) {
|
|
513
|
+
throw new Error("Expected onStepFinish first call");
|
|
514
|
+
}
|
|
515
|
+
expect(firstCall[0].toolCalls).toEqual([{ toolName: "myTool", argsTextLength: 13 }]);
|
|
516
|
+
expect(firstCall[0].toolResults).toEqual([{ toolName: "myTool", resultTextLength: 13 }]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("handles missing args/result properties in toolCalls/toolResults", async () => {
|
|
520
|
+
const onStepFinish = vi.fn();
|
|
521
|
+
|
|
522
|
+
mockGenerateText.mockImplementation(
|
|
523
|
+
async (opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
524
|
+
if (opts.onStepFinish) {
|
|
525
|
+
await opts.onStepFinish({
|
|
526
|
+
toolCalls: [{ toolName: "t" }],
|
|
527
|
+
toolResults: [{ toolName: "t" }],
|
|
528
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return createMockGenerateResult();
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
536
|
+
await a.generate("test");
|
|
537
|
+
|
|
538
|
+
expect(onStepFinish).toHaveBeenCalledTimes(1);
|
|
539
|
+
const firstCall = onStepFinish.mock.calls[0];
|
|
540
|
+
if (!firstCall) throw new Error("Expected onStepFinish first call");
|
|
541
|
+
// extractProperty returns {} when key is missing, safeSerializedLength({}) = 2
|
|
542
|
+
expect(firstCall[0].toolCalls).toEqual([{ toolName: "t", argsTextLength: 2 }]);
|
|
543
|
+
expect(firstCall[0].toolResults).toEqual([{ toolName: "t", resultTextLength: 2 }]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("handles undefined usage in onStepFinish", async () => {
|
|
547
|
+
const onStepFinish = vi.fn();
|
|
548
|
+
|
|
549
|
+
mockGenerateText.mockImplementation(
|
|
550
|
+
async (opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
551
|
+
if (opts.onStepFinish) {
|
|
552
|
+
await opts.onStepFinish({
|
|
553
|
+
toolCalls: [],
|
|
554
|
+
toolResults: [],
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return createMockGenerateResult();
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
562
|
+
await a.generate("test");
|
|
563
|
+
|
|
564
|
+
expect(onStepFinish).toHaveBeenCalledTimes(1);
|
|
565
|
+
const firstCall = onStepFinish.mock.calls[0];
|
|
566
|
+
if (!firstCall) throw new Error("Expected onStepFinish first call");
|
|
567
|
+
expect(firstCall[0].usage).toEqual({
|
|
568
|
+
inputTokens: 0,
|
|
569
|
+
outputTokens: 0,
|
|
570
|
+
totalTokens: 0,
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("handles circular reference in tool args gracefully", async () => {
|
|
575
|
+
const onStepFinish = vi.fn();
|
|
576
|
+
|
|
577
|
+
mockGenerateText.mockImplementation(
|
|
578
|
+
async (opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
579
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional circular ref for test
|
|
580
|
+
const circular: any = {};
|
|
581
|
+
circular.self = circular;
|
|
582
|
+
if (opts.onStepFinish) {
|
|
583
|
+
await opts.onStepFinish({
|
|
584
|
+
toolCalls: [{ toolName: "t", args: circular }],
|
|
585
|
+
toolResults: [],
|
|
586
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return createMockGenerateResult();
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
594
|
+
await a.generate("test");
|
|
595
|
+
|
|
596
|
+
expect(onStepFinish).toHaveBeenCalledTimes(1);
|
|
597
|
+
const firstCall = onStepFinish.mock.calls[0];
|
|
598
|
+
if (!firstCall) throw new Error("Expected onStepFinish first call");
|
|
599
|
+
// safeSerializedLength returns 0 for circular references
|
|
600
|
+
expect(firstCall[0].toolCalls).toEqual([{ toolName: "t", argsTextLength: 0 }]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("fires both config and override onStart hooks", async () => {
|
|
604
|
+
const configOnStart = vi.fn();
|
|
605
|
+
const overrideOnStart = vi.fn();
|
|
606
|
+
|
|
607
|
+
const a = createSimpleAgent({ onStart: configOnStart });
|
|
608
|
+
await a.generate("test", { onStart: overrideOnStart });
|
|
609
|
+
|
|
610
|
+
expect(configOnStart).toHaveBeenCalledTimes(1);
|
|
611
|
+
expect(overrideOnStart).toHaveBeenCalledTimes(1);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("fires both config and override onFinish hooks", async () => {
|
|
615
|
+
const configOnFinish = vi.fn();
|
|
616
|
+
const overrideOnFinish = vi.fn();
|
|
617
|
+
|
|
618
|
+
const a = createSimpleAgent({ onFinish: configOnFinish });
|
|
619
|
+
await a.generate("test", { onFinish: overrideOnFinish });
|
|
620
|
+
|
|
621
|
+
expect(configOnFinish).toHaveBeenCalledTimes(1);
|
|
622
|
+
expect(overrideOnFinish).toHaveBeenCalledTimes(1);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("fires both config and override onStepFinish hooks", async () => {
|
|
626
|
+
const configOnStepFinish = vi.fn();
|
|
627
|
+
const overrideOnStepFinish = vi.fn();
|
|
628
|
+
|
|
629
|
+
mockGenerateText.mockImplementation(
|
|
630
|
+
async (opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
631
|
+
if (opts.onStepFinish) {
|
|
632
|
+
await opts.onStepFinish({
|
|
633
|
+
toolCalls: [],
|
|
634
|
+
toolResults: [],
|
|
635
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return createMockGenerateResult();
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const a = createSimpleAgent({ onStepFinish: configOnStepFinish });
|
|
643
|
+
await a.generate("test", { onStepFinish: overrideOnStepFinish });
|
|
644
|
+
|
|
645
|
+
expect(configOnStepFinish).toHaveBeenCalledTimes(1);
|
|
646
|
+
expect(overrideOnStepFinish).toHaveBeenCalledTimes(1);
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// generate() — error handling
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
describe("generate() error handling", () => {
|
|
655
|
+
it("returns AGENT_ERROR when generateText throws an Error", async () => {
|
|
656
|
+
mockGenerateText.mockRejectedValue(new Error("model exploded"));
|
|
657
|
+
|
|
658
|
+
const a = createSimpleAgent();
|
|
659
|
+
const result = await a.generate("test");
|
|
660
|
+
|
|
661
|
+
expect(result.ok).toBe(false);
|
|
662
|
+
if (result.ok) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
expect(result.error.code).toBe("AGENT_ERROR");
|
|
666
|
+
expect(result.error.message).toBe("model exploded");
|
|
667
|
+
expect(result.error.cause).toBeInstanceOf(Error);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("wraps non-Error throws into Error with AGENT_ERROR code", async () => {
|
|
671
|
+
mockGenerateText.mockRejectedValue("string error");
|
|
672
|
+
|
|
673
|
+
const a = createSimpleAgent();
|
|
674
|
+
const result = await a.generate("test");
|
|
675
|
+
|
|
676
|
+
expect(result.ok).toBe(false);
|
|
677
|
+
if (result.ok) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
expect(result.error.code).toBe("AGENT_ERROR");
|
|
681
|
+
expect(result.error.message).toBe("string error");
|
|
682
|
+
expect(result.error.cause).toBeInstanceOf(Error);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("fires onError hook when generateText throws", async () => {
|
|
686
|
+
mockGenerateText.mockRejectedValue(new Error("boom"));
|
|
687
|
+
const onError = vi.fn();
|
|
688
|
+
|
|
689
|
+
const a = createSimpleAgent({ onError });
|
|
690
|
+
await a.generate("test");
|
|
691
|
+
|
|
692
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
693
|
+
const firstCall = onError.mock.calls[0];
|
|
694
|
+
if (!firstCall) {
|
|
695
|
+
throw new Error("Expected onError first call");
|
|
696
|
+
}
|
|
697
|
+
expect(firstCall[0].input).toBe("test");
|
|
698
|
+
expect(firstCall[0].error).toBeInstanceOf(Error);
|
|
699
|
+
expect(firstCall[0].error.message).toBe("boom");
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("fires both config and override onError hooks", async () => {
|
|
703
|
+
mockGenerateText.mockRejectedValue(new Error("fail"));
|
|
704
|
+
const configOnError = vi.fn();
|
|
705
|
+
const overrideOnError = vi.fn();
|
|
706
|
+
|
|
707
|
+
const a = createSimpleAgent({ onError: configOnError });
|
|
708
|
+
await a.generate("test", { onError: overrideOnError });
|
|
709
|
+
|
|
710
|
+
expect(configOnError).toHaveBeenCalledTimes(1);
|
|
711
|
+
expect(overrideOnError).toHaveBeenCalledTimes(1);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("does not fire onFinish when generateText throws", async () => {
|
|
715
|
+
mockGenerateText.mockRejectedValue(new Error("fail"));
|
|
716
|
+
const onFinish = vi.fn();
|
|
717
|
+
|
|
718
|
+
const a = createSimpleAgent({ onFinish });
|
|
719
|
+
await a.generate("test");
|
|
720
|
+
|
|
721
|
+
expect(onFinish).not.toHaveBeenCalled();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("does not fire onError on input validation failure", async () => {
|
|
725
|
+
const onError = vi.fn();
|
|
726
|
+
const a = createTypedAgent({ onError });
|
|
727
|
+
|
|
728
|
+
// @ts-expect-error - intentionally invalid input
|
|
729
|
+
await a.generate({ topic: 123 });
|
|
730
|
+
|
|
731
|
+
expect(onError).not.toHaveBeenCalled();
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
// generate() — hook resilience
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
describe("generate() hook resilience", () => {
|
|
740
|
+
it("onStart throwing does not prevent generation", async () => {
|
|
741
|
+
const a = createSimpleAgent({
|
|
742
|
+
onStart: () => {
|
|
743
|
+
throw new Error("onStart boom");
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const result = await a.generate("test");
|
|
748
|
+
|
|
749
|
+
expect(result.ok).toBe(true);
|
|
750
|
+
if (!result.ok) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
expect(result.output).toBe("mock response text");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("onFinish throwing does not break the result", async () => {
|
|
757
|
+
const a = createSimpleAgent({
|
|
758
|
+
onFinish: () => {
|
|
759
|
+
throw new Error("onFinish boom");
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const result = await a.generate("test");
|
|
764
|
+
|
|
765
|
+
expect(result.ok).toBe(true);
|
|
766
|
+
if (!result.ok) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
expect(result.output).toBe("mock response text");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("onError throwing does not break the error result", async () => {
|
|
773
|
+
mockGenerateText.mockRejectedValue(new Error("model fail"));
|
|
774
|
+
|
|
775
|
+
const a = createSimpleAgent({
|
|
776
|
+
onError: () => {
|
|
777
|
+
throw new Error("onError boom");
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const result = await a.generate("test");
|
|
782
|
+
|
|
783
|
+
expect(result.ok).toBe(false);
|
|
784
|
+
if (result.ok) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
expect(result.error.code).toBe("AGENT_ERROR");
|
|
788
|
+
expect(result.error.message).toBe("model fail");
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// generate() — overrides
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
describe("generate() overrides", () => {
|
|
797
|
+
it("uses override model when provided", async () => {
|
|
798
|
+
const overrideModel = { modelId: "override-model" } as never;
|
|
799
|
+
const a = createSimpleAgent();
|
|
800
|
+
await a.generate("test", { model: overrideModel });
|
|
801
|
+
|
|
802
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
803
|
+
if (!callArgs) {
|
|
804
|
+
throw new Error("Expected generateText to be called");
|
|
805
|
+
}
|
|
806
|
+
expect(callArgs[0].model).toBe(overrideModel);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("uses override system prompt when provided", async () => {
|
|
810
|
+
const a = createSimpleAgent({ system: "original" });
|
|
811
|
+
await a.generate("test", { system: "override system" });
|
|
812
|
+
|
|
813
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
814
|
+
if (!callArgs) {
|
|
815
|
+
throw new Error("Expected generateText to be called");
|
|
816
|
+
}
|
|
817
|
+
expect(callArgs[0].system).toBe("override system");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("passes abort signal from overrides", async () => {
|
|
821
|
+
const controller = new AbortController();
|
|
822
|
+
const a = createSimpleAgent();
|
|
823
|
+
await a.generate("test", { signal: controller.signal });
|
|
824
|
+
|
|
825
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
826
|
+
if (!callArgs) {
|
|
827
|
+
throw new Error("Expected generateText to be called");
|
|
828
|
+
}
|
|
829
|
+
expect(callArgs[0].abortSignal).toBe(controller.signal);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it("uses override logger when provided", async () => {
|
|
833
|
+
const overrideLogger = createMockLogger();
|
|
834
|
+
const a = createSimpleAgent();
|
|
835
|
+
await a.generate("test", { logger: overrideLogger });
|
|
836
|
+
|
|
837
|
+
expect(overrideLogger.child).toHaveBeenCalledWith({ agentId: "test-agent" });
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
// stream() — success path
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
describe("stream() success", () => {
|
|
846
|
+
it("returns ok: true with fullStream, output, messages, usage, and finishReason", async () => {
|
|
847
|
+
const a = createSimpleAgent();
|
|
848
|
+
const result = await a.stream("hello");
|
|
849
|
+
|
|
850
|
+
expect(result.ok).toBe(true);
|
|
851
|
+
if (!result.ok) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
expect(result.fullStream).toBeInstanceOf(ReadableStream);
|
|
855
|
+
expect(result.output).toBeInstanceOf(Promise);
|
|
856
|
+
expect(result.messages).toBeInstanceOf(Promise);
|
|
857
|
+
expect(result.usage).toBeInstanceOf(Promise);
|
|
858
|
+
expect(result.finishReason).toBeInstanceOf(Promise);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("fullStream emits typed StreamPart events", async () => {
|
|
862
|
+
mockStreamText.mockReturnValue(createMockStreamResult({ chunks: ["chunk1", "chunk2"] }));
|
|
863
|
+
|
|
864
|
+
const a = createSimpleAgent();
|
|
865
|
+
const result = await a.stream("hello");
|
|
866
|
+
|
|
867
|
+
expect(result.ok).toBe(true);
|
|
868
|
+
if (!result.ok) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const parts: unknown[] = [];
|
|
873
|
+
const reader = result.fullStream.getReader();
|
|
874
|
+
for (;;) {
|
|
875
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
876
|
+
const { done, value } = await reader.read();
|
|
877
|
+
if (done) {
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
parts.push(value);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
expect(parts).toEqual([
|
|
884
|
+
{ type: "text-delta", textDelta: "chunk1" },
|
|
885
|
+
{ type: "text-delta", textDelta: "chunk2" },
|
|
886
|
+
]);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("output promise resolves to text after stream completes", async () => {
|
|
890
|
+
mockStreamText.mockReturnValue(createMockStreamResult({ text: "full text" }));
|
|
891
|
+
|
|
892
|
+
const a = createSimpleAgent();
|
|
893
|
+
const result = await a.stream("hello");
|
|
894
|
+
|
|
895
|
+
expect(result.ok).toBe(true);
|
|
896
|
+
if (!result.ok) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Drain the stream to complete
|
|
901
|
+
const reader = result.fullStream.getReader();
|
|
902
|
+
for (;;) {
|
|
903
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
904
|
+
const { done } = await reader.read();
|
|
905
|
+
if (done) {
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const output = await result.output;
|
|
911
|
+
expect(output).toBe("full text");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("messages promise resolves after stream completes", async () => {
|
|
915
|
+
const expectedMessages = [{ role: "assistant", content: "msg" }];
|
|
916
|
+
mockStreamText.mockReturnValue(
|
|
917
|
+
createMockStreamResult({ response: { messages: expectedMessages } }),
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
const a = createSimpleAgent();
|
|
921
|
+
const result = await a.stream("hello");
|
|
922
|
+
|
|
923
|
+
expect(result.ok).toBe(true);
|
|
924
|
+
if (!result.ok) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Drain the stream to complete
|
|
929
|
+
const reader = result.fullStream.getReader();
|
|
930
|
+
for (;;) {
|
|
931
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
932
|
+
const { done } = await reader.read();
|
|
933
|
+
if (done) {
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const messages = await result.messages;
|
|
939
|
+
expect(messages).toEqual(expectedMessages);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("usage and finishReason promises resolve after stream completes", async () => {
|
|
943
|
+
const a = createSimpleAgent();
|
|
944
|
+
const result = await a.stream("hello");
|
|
945
|
+
|
|
946
|
+
expect(result.ok).toBe(true);
|
|
947
|
+
if (!result.ok) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Drain the stream to complete
|
|
952
|
+
const reader = result.fullStream.getReader();
|
|
953
|
+
for (;;) {
|
|
954
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
955
|
+
const { done } = await reader.read();
|
|
956
|
+
if (done) {
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const usage = await result.usage;
|
|
962
|
+
expect(usage).toEqual({
|
|
963
|
+
inputTokens: 100,
|
|
964
|
+
outputTokens: 50,
|
|
965
|
+
totalTokens: 150,
|
|
966
|
+
cacheReadTokens: 10,
|
|
967
|
+
cacheWriteTokens: 5,
|
|
968
|
+
reasoningTokens: 3,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
const finishReason = await result.finishReason;
|
|
972
|
+
expect(finishReason).toBe("stop");
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// stream() — input validation
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
describe("stream() input validation", () => {
|
|
981
|
+
it("returns VALIDATION_ERROR when typed input fails safeParse", async () => {
|
|
982
|
+
const a = createTypedAgent();
|
|
983
|
+
|
|
984
|
+
// @ts-expect-error - intentionally invalid input
|
|
985
|
+
const result = await a.stream({ topic: 123 });
|
|
986
|
+
|
|
987
|
+
expect(result.ok).toBe(false);
|
|
988
|
+
if (result.ok) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
992
|
+
expect(result.error.message).toContain("Input validation failed");
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it("does not call streamText when input validation fails", async () => {
|
|
996
|
+
const a = createTypedAgent();
|
|
997
|
+
|
|
998
|
+
// @ts-expect-error - intentionally invalid input
|
|
999
|
+
await a.stream({ topic: 123 });
|
|
1000
|
+
|
|
1001
|
+
expect(mockStreamText).not.toHaveBeenCalled();
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
// stream() — hooks
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
|
|
1009
|
+
describe("stream() hooks", () => {
|
|
1010
|
+
it("fires onStart hook with input", async () => {
|
|
1011
|
+
const onStart = vi.fn();
|
|
1012
|
+
const a = createSimpleAgent({ onStart });
|
|
1013
|
+
await a.stream("hello");
|
|
1014
|
+
|
|
1015
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
1016
|
+
const firstCall = onStart.mock.calls[0];
|
|
1017
|
+
if (!firstCall) {
|
|
1018
|
+
throw new Error("Expected onStart first call");
|
|
1019
|
+
}
|
|
1020
|
+
expect(firstCall[0]).toEqual({ input: "hello" });
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("fires onFinish hook after stream completes", async () => {
|
|
1024
|
+
const onFinish = vi.fn();
|
|
1025
|
+
const a = createSimpleAgent({ onFinish });
|
|
1026
|
+
const result = await a.stream("hello");
|
|
1027
|
+
|
|
1028
|
+
expect(result.ok).toBe(true);
|
|
1029
|
+
if (!result.ok) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Drain the stream to trigger onFinish
|
|
1034
|
+
const reader = result.fullStream.getReader();
|
|
1035
|
+
for (;;) {
|
|
1036
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
1037
|
+
const { done } = await reader.read();
|
|
1038
|
+
if (done) {
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Wait for the done promise to settle
|
|
1044
|
+
await result.output;
|
|
1045
|
+
|
|
1046
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
1047
|
+
const firstCall = onFinish.mock.calls[0];
|
|
1048
|
+
if (!firstCall) {
|
|
1049
|
+
throw new Error("Expected onFinish first call");
|
|
1050
|
+
}
|
|
1051
|
+
expect(firstCall[0].input).toBe("hello");
|
|
1052
|
+
expect(firstCall[0].result).toHaveProperty("output");
|
|
1053
|
+
expect(firstCall[0].duration).toBeGreaterThanOrEqual(0);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("fires onStepFinish hook during stream tool loop", async () => {
|
|
1057
|
+
const onStepFinish = vi.fn();
|
|
1058
|
+
|
|
1059
|
+
const streamResult = createMockStreamResult();
|
|
1060
|
+
mockStreamText.mockImplementation(
|
|
1061
|
+
(opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
1062
|
+
// Simulate step callbacks synchronously before the stream starts
|
|
1063
|
+
if (opts.onStepFinish) {
|
|
1064
|
+
void opts.onStepFinish({
|
|
1065
|
+
toolCalls: [],
|
|
1066
|
+
toolResults: [],
|
|
1067
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return streamResult;
|
|
1071
|
+
},
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
1075
|
+
const result = await a.stream("test");
|
|
1076
|
+
|
|
1077
|
+
expect(result.ok).toBe(true);
|
|
1078
|
+
if (!result.ok) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Drain the stream
|
|
1083
|
+
const reader = result.fullStream.getReader();
|
|
1084
|
+
for (;;) {
|
|
1085
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
1086
|
+
const { done } = await reader.read();
|
|
1087
|
+
if (done) {
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Allow microtasks to settle
|
|
1093
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1094
|
+
|
|
1095
|
+
expect(onStepFinish).toHaveBeenCalled();
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it("fires onStepFinish with mapped toolCalls and toolResults", async () => {
|
|
1099
|
+
const onStepFinish = vi.fn();
|
|
1100
|
+
|
|
1101
|
+
const streamResult = createMockStreamResult();
|
|
1102
|
+
mockStreamText.mockImplementation(
|
|
1103
|
+
(opts: { onStepFinish?: (step: Record<string, unknown>) => Promise<void> }) => {
|
|
1104
|
+
if (opts.onStepFinish) {
|
|
1105
|
+
void opts.onStepFinish({
|
|
1106
|
+
toolCalls: [{ toolName: "myTool", args: { foo: "bar" } }],
|
|
1107
|
+
toolResults: [{ toolName: "myTool", result: { answer: 42 } }],
|
|
1108
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return streamResult;
|
|
1112
|
+
},
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
const a = createSimpleAgent({ onStepFinish });
|
|
1116
|
+
const result = await a.stream("test");
|
|
1117
|
+
|
|
1118
|
+
expect(result.ok).toBe(true);
|
|
1119
|
+
if (!result.ok) {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Drain the stream
|
|
1124
|
+
const reader = result.fullStream.getReader();
|
|
1125
|
+
for (;;) {
|
|
1126
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption requires awaiting each read
|
|
1127
|
+
const { done } = await reader.read();
|
|
1128
|
+
if (done) {
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Allow microtasks to settle
|
|
1134
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1135
|
+
|
|
1136
|
+
expect(onStepFinish).toHaveBeenCalledTimes(1);
|
|
1137
|
+
const firstCall = onStepFinish.mock.calls[0];
|
|
1138
|
+
if (!firstCall) {
|
|
1139
|
+
throw new Error("Expected onStepFinish first call");
|
|
1140
|
+
}
|
|
1141
|
+
expect(firstCall[0].stepId).toBe("test-agent:0");
|
|
1142
|
+
expect(firstCall[0].toolCalls).toEqual([{ toolName: "myTool", argsTextLength: 13 }]);
|
|
1143
|
+
expect(firstCall[0].toolResults).toEqual([{ toolName: "myTool", resultTextLength: 13 }]);
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// ---------------------------------------------------------------------------
|
|
1148
|
+
// stream() — error handling
|
|
1149
|
+
// ---------------------------------------------------------------------------
|
|
1150
|
+
|
|
1151
|
+
describe("stream() error handling", () => {
|
|
1152
|
+
it("returns AGENT_ERROR when streamText throws synchronously", async () => {
|
|
1153
|
+
mockStreamText.mockImplementation(() => {
|
|
1154
|
+
throw new Error("stream setup failed");
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
const a = createSimpleAgent();
|
|
1158
|
+
const result = await a.stream("test");
|
|
1159
|
+
|
|
1160
|
+
expect(result.ok).toBe(false);
|
|
1161
|
+
if (result.ok) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
expect(result.error.code).toBe("AGENT_ERROR");
|
|
1165
|
+
expect(result.error.message).toBe("stream setup failed");
|
|
1166
|
+
expect(result.error.cause).toBeInstanceOf(Error);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("fires onError when streamText throws synchronously", async () => {
|
|
1170
|
+
mockStreamText.mockImplementation(() => {
|
|
1171
|
+
throw new Error("setup fail");
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
const onError = vi.fn();
|
|
1175
|
+
const a = createSimpleAgent({ onError });
|
|
1176
|
+
await a.stream("test");
|
|
1177
|
+
|
|
1178
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
1179
|
+
const firstCall = onError.mock.calls[0];
|
|
1180
|
+
if (!firstCall) {
|
|
1181
|
+
throw new Error("Expected onError first call");
|
|
1182
|
+
}
|
|
1183
|
+
expect(firstCall[0].error.message).toBe("setup fail");
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it("wraps non-Error throws into Error", async () => {
|
|
1187
|
+
mockStreamText.mockImplementation(() => {
|
|
1188
|
+
throw "string stream error";
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
const a = createSimpleAgent();
|
|
1192
|
+
const result = await a.stream("test");
|
|
1193
|
+
|
|
1194
|
+
expect(result.ok).toBe(false);
|
|
1195
|
+
if (result.ok) {
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
expect(result.error.code).toBe("AGENT_ERROR");
|
|
1199
|
+
expect(result.error.message).toBe("string stream error");
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it("does not fire onError on input validation failure", async () => {
|
|
1203
|
+
const onError = vi.fn();
|
|
1204
|
+
const a = createTypedAgent({ onError });
|
|
1205
|
+
|
|
1206
|
+
// @ts-expect-error - intentionally invalid input
|
|
1207
|
+
await a.stream({ topic: 123 });
|
|
1208
|
+
|
|
1209
|
+
expect(onError).not.toHaveBeenCalled();
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("does not fire onFinish when streamText throws synchronously", async () => {
|
|
1213
|
+
mockStreamText.mockImplementation(() => {
|
|
1214
|
+
throw new Error("setup fail");
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
const onFinish = vi.fn();
|
|
1218
|
+
const a = createSimpleAgent({ onFinish });
|
|
1219
|
+
await a.stream("test");
|
|
1220
|
+
|
|
1221
|
+
expect(onFinish).not.toHaveBeenCalled();
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
// ---------------------------------------------------------------------------
|
|
1226
|
+
// stream() — overrides
|
|
1227
|
+
// ---------------------------------------------------------------------------
|
|
1228
|
+
|
|
1229
|
+
describe("stream() overrides", () => {
|
|
1230
|
+
it("uses override model when provided", async () => {
|
|
1231
|
+
const overrideModel = { modelId: "stream-override" } as never;
|
|
1232
|
+
const a = createSimpleAgent();
|
|
1233
|
+
await a.stream("test", { model: overrideModel });
|
|
1234
|
+
|
|
1235
|
+
const callArgs = mockStreamText.mock.calls[0];
|
|
1236
|
+
if (!callArgs) {
|
|
1237
|
+
throw new Error("Expected streamText to be called");
|
|
1238
|
+
}
|
|
1239
|
+
expect(callArgs[0].model).toBe(overrideModel);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it("passes abort signal from overrides", async () => {
|
|
1243
|
+
const controller = new AbortController();
|
|
1244
|
+
const a = createSimpleAgent();
|
|
1245
|
+
await a.stream("test", { signal: controller.signal });
|
|
1246
|
+
|
|
1247
|
+
const callArgs = mockStreamText.mock.calls[0];
|
|
1248
|
+
if (!callArgs) {
|
|
1249
|
+
throw new Error("Expected streamText to be called");
|
|
1250
|
+
}
|
|
1251
|
+
expect(callArgs[0].abortSignal).toBe(controller.signal);
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
// ---------------------------------------------------------------------------
|
|
1256
|
+
// fn() — delegates to generate()
|
|
1257
|
+
// ---------------------------------------------------------------------------
|
|
1258
|
+
|
|
1259
|
+
describe("fn()", () => {
|
|
1260
|
+
it("returns a function that delegates to generate()", async () => {
|
|
1261
|
+
const a = createSimpleAgent();
|
|
1262
|
+
const fn = a.fn();
|
|
1263
|
+
|
|
1264
|
+
const result = await fn("hello");
|
|
1265
|
+
|
|
1266
|
+
expect(result.ok).toBe(true);
|
|
1267
|
+
if (!result.ok) {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
expect(result.output).toBe("mock response text");
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("fn() produces the same results as generate()", async () => {
|
|
1274
|
+
const a = createSimpleAgent();
|
|
1275
|
+
const fn = a.fn();
|
|
1276
|
+
|
|
1277
|
+
const resultGenerate = await a.generate("test");
|
|
1278
|
+
const resultFn = await fn("test");
|
|
1279
|
+
|
|
1280
|
+
expect(resultGenerate.ok).toBe(true);
|
|
1281
|
+
expect(resultFn.ok).toBe(true);
|
|
1282
|
+
if (!resultGenerate.ok || !resultFn.ok) {
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
expect(resultGenerate.output).toEqual(resultFn.output);
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it("fn() passes overrides through to generate", async () => {
|
|
1289
|
+
const onStart = vi.fn();
|
|
1290
|
+
const a = createSimpleAgent();
|
|
1291
|
+
const fn = a.fn();
|
|
1292
|
+
|
|
1293
|
+
await fn("test", { onStart });
|
|
1294
|
+
|
|
1295
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("fn() handles validation errors", async () => {
|
|
1299
|
+
const a = createTypedAgent();
|
|
1300
|
+
const fn = a.fn();
|
|
1301
|
+
|
|
1302
|
+
// @ts-expect-error - intentionally invalid input
|
|
1303
|
+
const result = await fn({ topic: 123 });
|
|
1304
|
+
|
|
1305
|
+
expect(result.ok).toBe(false);
|
|
1306
|
+
if (result.ok) {
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
// ---------------------------------------------------------------------------
|
|
1314
|
+
// Tool integration
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
|
|
1317
|
+
describe("tool integration", () => {
|
|
1318
|
+
it("passes tools to generateText when tools are configured", async () => {
|
|
1319
|
+
const mockTool = {
|
|
1320
|
+
description: "mock tool",
|
|
1321
|
+
inputSchema: { jsonSchema: {} },
|
|
1322
|
+
execute: vi.fn(),
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
const a = createSimpleAgent({ tools: { myTool: mockTool as never } });
|
|
1326
|
+
await a.generate("test");
|
|
1327
|
+
|
|
1328
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
1329
|
+
if (!callArgs) {
|
|
1330
|
+
throw new Error("Expected generateText to be called");
|
|
1331
|
+
}
|
|
1332
|
+
expect(callArgs[0].tools).toBeDefined();
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
it("passes no tools to generateText when no tools are configured", async () => {
|
|
1336
|
+
const a = createSimpleAgent();
|
|
1337
|
+
await a.generate("test");
|
|
1338
|
+
|
|
1339
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
1340
|
+
if (!callArgs) {
|
|
1341
|
+
throw new Error("Expected generateText to be called");
|
|
1342
|
+
}
|
|
1343
|
+
expect(callArgs[0].tools).toBeUndefined();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it("merges override tools with config tools", async () => {
|
|
1347
|
+
const configTool = { description: "config", inputSchema: { jsonSchema: {} }, execute: vi.fn() };
|
|
1348
|
+
const overrideTool = {
|
|
1349
|
+
description: "override",
|
|
1350
|
+
inputSchema: { jsonSchema: {} },
|
|
1351
|
+
execute: vi.fn(),
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
const a = createSimpleAgent({ tools: { configTool: configTool as never } });
|
|
1355
|
+
await a.generate("test", { tools: { overrideTool: overrideTool as never } });
|
|
1356
|
+
|
|
1357
|
+
const callArgs = mockGenerateText.mock.calls[0];
|
|
1358
|
+
if (!callArgs) {
|
|
1359
|
+
throw new Error("Expected generateText to be called");
|
|
1360
|
+
}
|
|
1361
|
+
expect(callArgs[0].tools).toBeDefined();
|
|
1362
|
+
});
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
// Edge cases
|
|
1367
|
+
// ---------------------------------------------------------------------------
|
|
1368
|
+
|
|
1369
|
+
describe("edge cases", () => {
|
|
1370
|
+
it("handles undefined overrides gracefully", async () => {
|
|
1371
|
+
const a = createSimpleAgent();
|
|
1372
|
+
const result = await a.generate("test", undefined);
|
|
1373
|
+
|
|
1374
|
+
expect(result.ok).toBe(true);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it("handles empty string input for simple agent", async () => {
|
|
1378
|
+
const a = createSimpleAgent();
|
|
1379
|
+
const result = await a.generate("");
|
|
1380
|
+
|
|
1381
|
+
expect(result.ok).toBe(true);
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
it("model string ID is resolved via openrouter", async () => {
|
|
1385
|
+
const a = agent({
|
|
1386
|
+
name: "string-model-agent",
|
|
1387
|
+
model: "openai/gpt-4.1",
|
|
1388
|
+
system: "test",
|
|
1389
|
+
logger: createMockLogger(),
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
await a.generate("test");
|
|
1393
|
+
|
|
1394
|
+
const { openrouter: mockOpenrouter } = await import("@/core/provider/provider.js");
|
|
1395
|
+
expect(mockOpenrouter).toHaveBeenCalledWith("openai/gpt-4.1");
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
it("uses default logger when none provided", async () => {
|
|
1399
|
+
const a = agent({
|
|
1400
|
+
name: "no-logger-agent",
|
|
1401
|
+
model: { modelId: "mock" } as never,
|
|
1402
|
+
system: "test",
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
// Should not throw when no logger is provided
|
|
1406
|
+
const result = await a.generate("test");
|
|
1407
|
+
expect(result.ok).toBe(true);
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
// ---------------------------------------------------------------------------
|
|
1412
|
+
// stream() — async error during consumption
|
|
1413
|
+
// ---------------------------------------------------------------------------
|
|
1414
|
+
|
|
1415
|
+
describe("stream() async error during consumption", () => {
|
|
1416
|
+
function createErrorStreamResult(error: Error) {
|
|
1417
|
+
async function* makeFullStream() {
|
|
1418
|
+
yield { type: "text-delta" as const, textDelta: "partial" };
|
|
1419
|
+
throw error;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const rejected = <T>(): Promise<T> => {
|
|
1423
|
+
const p = Promise.reject(error);
|
|
1424
|
+
p.catch(() => {});
|
|
1425
|
+
return p;
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
return {
|
|
1429
|
+
fullStream: makeFullStream(),
|
|
1430
|
+
text: rejected<string>(),
|
|
1431
|
+
output: rejected<unknown>(),
|
|
1432
|
+
response: rejected<{ messages: unknown[] }>(),
|
|
1433
|
+
totalUsage: rejected<typeof MOCK_TOTAL_USAGE>(),
|
|
1434
|
+
finishReason: rejected<string>(),
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
it("closes the stream when fullStream throws during iteration", async () => {
|
|
1439
|
+
const streamError = new Error("async stream failure");
|
|
1440
|
+
mockStreamText.mockReturnValue(createErrorStreamResult(streamError));
|
|
1441
|
+
|
|
1442
|
+
const a = createSimpleAgent();
|
|
1443
|
+
const result = await a.stream("test");
|
|
1444
|
+
|
|
1445
|
+
expect(result.ok).toBe(true);
|
|
1446
|
+
if (!result.ok) return;
|
|
1447
|
+
|
|
1448
|
+
// Suppress derived promise rejections
|
|
1449
|
+
result.output.catch(() => {});
|
|
1450
|
+
result.messages.catch(() => {});
|
|
1451
|
+
result.usage.catch(() => {});
|
|
1452
|
+
result.finishReason.catch(() => {});
|
|
1453
|
+
|
|
1454
|
+
// Drain the stream — writer.abort() errors the readable side, so
|
|
1455
|
+
// reader.read() will reject once the error propagates.
|
|
1456
|
+
const parts: unknown[] = [];
|
|
1457
|
+
const reader = result.fullStream.getReader();
|
|
1458
|
+
let streamErrored = false;
|
|
1459
|
+
for (;;) {
|
|
1460
|
+
try {
|
|
1461
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption
|
|
1462
|
+
const { done, value } = await reader.read();
|
|
1463
|
+
if (done) break;
|
|
1464
|
+
parts.push(value);
|
|
1465
|
+
} catch {
|
|
1466
|
+
streamErrored = true;
|
|
1467
|
+
break;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Should have received the partial chunk before error closed the stream
|
|
1472
|
+
expect(parts.length).toBeGreaterThanOrEqual(1);
|
|
1473
|
+
expect(parts[0]).toEqual({ type: "text-delta", textDelta: "partial" });
|
|
1474
|
+
expect(streamErrored).toBe(true);
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
it("fires onError hook when fullStream throws during iteration", async () => {
|
|
1478
|
+
const streamError = new Error("async hook error");
|
|
1479
|
+
mockStreamText.mockReturnValue(createErrorStreamResult(streamError));
|
|
1480
|
+
|
|
1481
|
+
const onError = vi.fn();
|
|
1482
|
+
const onFinish = vi.fn();
|
|
1483
|
+
const a = createSimpleAgent({ onError, onFinish });
|
|
1484
|
+
const result = await a.stream("test");
|
|
1485
|
+
|
|
1486
|
+
expect(result.ok).toBe(true);
|
|
1487
|
+
if (!result.ok) return;
|
|
1488
|
+
|
|
1489
|
+
result.output.catch(() => {});
|
|
1490
|
+
result.messages.catch(() => {});
|
|
1491
|
+
result.usage.catch(() => {});
|
|
1492
|
+
result.finishReason.catch(() => {});
|
|
1493
|
+
|
|
1494
|
+
// Drain the stream to trigger the error — reader.read() rejects
|
|
1495
|
+
// once the writer aborts the transform stream.
|
|
1496
|
+
const reader = result.fullStream.getReader();
|
|
1497
|
+
for (;;) {
|
|
1498
|
+
try {
|
|
1499
|
+
// eslint-disable-next-line no-await-in-loop -- Sequential stream consumption
|
|
1500
|
+
const { done } = await reader.read();
|
|
1501
|
+
if (done) break;
|
|
1502
|
+
} catch {
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// Wait for async error handling to settle
|
|
1508
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1509
|
+
|
|
1510
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
1511
|
+
const firstCall = onError.mock.calls[0];
|
|
1512
|
+
if (!firstCall) {
|
|
1513
|
+
throw new Error("Expected onError first call");
|
|
1514
|
+
}
|
|
1515
|
+
expect(firstCall[0].input).toBe("test");
|
|
1516
|
+
expect(firstCall[0].error).toBeInstanceOf(Error);
|
|
1517
|
+
expect(firstCall[0].error.message).toBe("async hook error");
|
|
1518
|
+
|
|
1519
|
+
// onFinish should NOT be called when the stream errors
|
|
1520
|
+
expect(onFinish).not.toHaveBeenCalled();
|
|
1521
|
+
});
|
|
1522
|
+
});
|