@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,1027 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { flowAgent } from "@/core/agents/flow/flow-agent.js";
|
|
5
|
+
import { RUNNABLE_META, type RunnableMeta } from "@/lib/runnable.js";
|
|
6
|
+
import { createMockLogger } from "@/testing/index.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const Input = z.object({ x: z.number() });
|
|
13
|
+
const Output = z.object({ y: z.number() });
|
|
14
|
+
|
|
15
|
+
function createSimpleFlowAgent(
|
|
16
|
+
overrides?: Partial<Parameters<typeof flowAgent<{ x: number }, { y: number }>>[0]>,
|
|
17
|
+
handler?: Parameters<typeof flowAgent<{ x: number }, { y: number }>>[1],
|
|
18
|
+
) {
|
|
19
|
+
return flowAgent<{ x: number }, { y: number }>(
|
|
20
|
+
{
|
|
21
|
+
name: "test-flow",
|
|
22
|
+
input: Input,
|
|
23
|
+
output: Output,
|
|
24
|
+
logger: createMockLogger(),
|
|
25
|
+
...overrides,
|
|
26
|
+
},
|
|
27
|
+
handler ?? (async ({ input }) => ({ y: input.x * 2 })),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Setup
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// FlowAgent creation
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe("flowAgent creation", () => {
|
|
44
|
+
it("returns an object with generate, stream, and fn methods", () => {
|
|
45
|
+
const fa = createSimpleFlowAgent();
|
|
46
|
+
|
|
47
|
+
expect(typeof fa.generate).toBe("function");
|
|
48
|
+
expect(typeof fa.stream).toBe("function");
|
|
49
|
+
expect(typeof fa.fn).toBe("function");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("attaches RUNNABLE_META with name and inputSchema", () => {
|
|
53
|
+
const fa = createSimpleFlowAgent();
|
|
54
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
55
|
+
const meta = (fa as unknown as Record<symbol, unknown>)[RUNNABLE_META] as RunnableMeta;
|
|
56
|
+
|
|
57
|
+
expect(meta.name).toBe("test-flow");
|
|
58
|
+
expect(meta.inputSchema).toBe(Input);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// generate() — success path
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe("generate() success", () => {
|
|
67
|
+
it("returns ok: true with computed output", async () => {
|
|
68
|
+
const fa = createSimpleFlowAgent();
|
|
69
|
+
const result = await fa.generate({ x: 5 });
|
|
70
|
+
|
|
71
|
+
expect(result.ok).toBe(true);
|
|
72
|
+
if (!result.ok) return;
|
|
73
|
+
expect(result.output).toEqual({ y: 10 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("includes messages array with user and assistant messages", async () => {
|
|
77
|
+
const fa = createSimpleFlowAgent();
|
|
78
|
+
const result = await fa.generate({ x: 3 });
|
|
79
|
+
|
|
80
|
+
expect(result.ok).toBe(true);
|
|
81
|
+
if (!result.ok) return;
|
|
82
|
+
expect(result.messages.length).toBeGreaterThanOrEqual(2);
|
|
83
|
+
expect(result.messages[0]?.role).toBe("user");
|
|
84
|
+
expect(result.messages[result.messages.length - 1]?.role).toBe("assistant");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("includes usage with zero-valued fields when no sub-agents run", async () => {
|
|
88
|
+
const fa = createSimpleFlowAgent();
|
|
89
|
+
const result = await fa.generate({ x: 1 });
|
|
90
|
+
|
|
91
|
+
expect(result.ok).toBe(true);
|
|
92
|
+
if (!result.ok) return;
|
|
93
|
+
expect(result.usage).toEqual({
|
|
94
|
+
inputTokens: 0,
|
|
95
|
+
outputTokens: 0,
|
|
96
|
+
totalTokens: 0,
|
|
97
|
+
cacheReadTokens: 0,
|
|
98
|
+
cacheWriteTokens: 0,
|
|
99
|
+
reasoningTokens: 0,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("includes finishReason of stop", async () => {
|
|
104
|
+
const fa = createSimpleFlowAgent();
|
|
105
|
+
const result = await fa.generate({ x: 1 });
|
|
106
|
+
|
|
107
|
+
expect(result.ok).toBe(true);
|
|
108
|
+
if (!result.ok) return;
|
|
109
|
+
expect(result.finishReason).toBe("stop");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("includes trace array", async () => {
|
|
113
|
+
const fa = createSimpleFlowAgent();
|
|
114
|
+
const result = await fa.generate({ x: 1 });
|
|
115
|
+
|
|
116
|
+
expect(result.ok).toBe(true);
|
|
117
|
+
if (!result.ok) return;
|
|
118
|
+
expect(result.trace).toBeInstanceOf(Array);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("includes duration >= 0", async () => {
|
|
122
|
+
const fa = createSimpleFlowAgent();
|
|
123
|
+
const result = await fa.generate({ x: 1 });
|
|
124
|
+
|
|
125
|
+
expect(result.ok).toBe(true);
|
|
126
|
+
if (!result.ok) return;
|
|
127
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// generate() — with steps
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describe("generate() with steps", () => {
|
|
136
|
+
it("handler receives $ step builder and can use $.step()", async () => {
|
|
137
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
138
|
+
{
|
|
139
|
+
name: "step-flow",
|
|
140
|
+
input: Input,
|
|
141
|
+
output: Output,
|
|
142
|
+
logger: createMockLogger(),
|
|
143
|
+
},
|
|
144
|
+
async ({ input, $ }) => {
|
|
145
|
+
const result = await $.step({
|
|
146
|
+
id: "double",
|
|
147
|
+
execute: async () => input.x * 2,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { y: result.ok ? result.value : 0 };
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const result = await fa.generate({ x: 7 });
|
|
155
|
+
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
if (!result.ok) return;
|
|
158
|
+
expect(result.output).toEqual({ y: 14 });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("steps produce synthetic tool-call messages in the messages array", async () => {
|
|
162
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
163
|
+
{
|
|
164
|
+
name: "msg-flow",
|
|
165
|
+
input: Input,
|
|
166
|
+
output: Output,
|
|
167
|
+
logger: createMockLogger(),
|
|
168
|
+
},
|
|
169
|
+
async ({ input, $ }) => {
|
|
170
|
+
await $.step({
|
|
171
|
+
id: "compute",
|
|
172
|
+
execute: async () => input.x + 1,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return { y: input.x + 1 };
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const result = await fa.generate({ x: 5 });
|
|
180
|
+
|
|
181
|
+
expect(result.ok).toBe(true);
|
|
182
|
+
if (!result.ok) return;
|
|
183
|
+
|
|
184
|
+
// Should have: user msg, tool-call msg, tool-result msg, assistant msg
|
|
185
|
+
expect(result.messages.length).toBeGreaterThanOrEqual(4);
|
|
186
|
+
|
|
187
|
+
const toolCallMsg = result.messages.find(
|
|
188
|
+
(m) =>
|
|
189
|
+
m.role === "assistant" &&
|
|
190
|
+
Array.isArray(m.content) &&
|
|
191
|
+
(m.content as Array<{ type: string }>).some((p) => p.type === "tool-call"),
|
|
192
|
+
);
|
|
193
|
+
expect(toolCallMsg).toBeDefined();
|
|
194
|
+
|
|
195
|
+
const toolResultMsg = result.messages.find(
|
|
196
|
+
(m) =>
|
|
197
|
+
m.role === "tool" &&
|
|
198
|
+
Array.isArray(m.content) &&
|
|
199
|
+
(m.content as Array<{ type: string }>).some((p) => p.type === "tool-result"),
|
|
200
|
+
);
|
|
201
|
+
expect(toolResultMsg).toBeDefined();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// generate() — input validation
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
describe("generate() input validation", () => {
|
|
210
|
+
it("returns VALIDATION_ERROR when input fails safeParse", async () => {
|
|
211
|
+
const fa = createSimpleFlowAgent();
|
|
212
|
+
|
|
213
|
+
// @ts-expect-error - intentionally invalid input
|
|
214
|
+
const result = await fa.generate({ x: "not-a-number" });
|
|
215
|
+
|
|
216
|
+
expect(result.ok).toBe(false);
|
|
217
|
+
if (result.ok) return;
|
|
218
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
219
|
+
expect(result.error.message).toContain("Input validation failed");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns VALIDATION_ERROR when required fields are missing", async () => {
|
|
223
|
+
const fa = createSimpleFlowAgent();
|
|
224
|
+
|
|
225
|
+
// @ts-expect-error - intentionally missing field
|
|
226
|
+
const result = await fa.generate({});
|
|
227
|
+
|
|
228
|
+
expect(result.ok).toBe(false);
|
|
229
|
+
if (result.ok) return;
|
|
230
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not call handler when input validation fails", async () => {
|
|
234
|
+
const handler = vi.fn(async ({ input }: { input: { x: number } }) => ({ y: input.x }));
|
|
235
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
236
|
+
{
|
|
237
|
+
name: "test",
|
|
238
|
+
input: Input,
|
|
239
|
+
output: Output,
|
|
240
|
+
logger: createMockLogger(),
|
|
241
|
+
},
|
|
242
|
+
handler as never,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// @ts-expect-error - intentionally invalid input
|
|
246
|
+
await fa.generate({ x: "bad" });
|
|
247
|
+
|
|
248
|
+
expect(handler).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// generate() — output validation
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
describe("generate() output validation", () => {
|
|
257
|
+
it("returns VALIDATION_ERROR when output fails safeParse", async () => {
|
|
258
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
259
|
+
{
|
|
260
|
+
name: "test",
|
|
261
|
+
input: Input,
|
|
262
|
+
output: Output,
|
|
263
|
+
logger: createMockLogger(),
|
|
264
|
+
},
|
|
265
|
+
async () => ({ y: "not-a-number" }) as unknown as { y: number },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const result = await fa.generate({ x: 1 });
|
|
269
|
+
|
|
270
|
+
expect(result.ok).toBe(false);
|
|
271
|
+
if (result.ok) return;
|
|
272
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
273
|
+
expect(result.error.message).toContain("Output validation failed");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// generate() — error handling
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
describe("generate() error handling", () => {
|
|
282
|
+
it("returns FLOW_AGENT_ERROR when handler throws an Error", async () => {
|
|
283
|
+
const fa = createSimpleFlowAgent(undefined, async () => {
|
|
284
|
+
throw new Error("handler exploded");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const result = await fa.generate({ x: 1 });
|
|
288
|
+
|
|
289
|
+
expect(result.ok).toBe(false);
|
|
290
|
+
if (result.ok) return;
|
|
291
|
+
expect(result.error.code).toBe("FLOW_AGENT_ERROR");
|
|
292
|
+
expect(result.error.message).toBe("handler exploded");
|
|
293
|
+
expect(result.error.cause).toBeInstanceOf(Error);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("wraps non-Error throws into Error with FLOW_AGENT_ERROR code", async () => {
|
|
297
|
+
const fa = createSimpleFlowAgent(undefined, async () => {
|
|
298
|
+
throw "string error";
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const result = await fa.generate({ x: 1 });
|
|
302
|
+
|
|
303
|
+
expect(result.ok).toBe(false);
|
|
304
|
+
if (result.ok) return;
|
|
305
|
+
expect(result.error.code).toBe("FLOW_AGENT_ERROR");
|
|
306
|
+
expect(result.error.message).toBe("string error");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// generate() — hooks
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
describe("generate() hooks", () => {
|
|
315
|
+
it("fires onStart hook with input", async () => {
|
|
316
|
+
const onStart = vi.fn();
|
|
317
|
+
const fa = createSimpleFlowAgent({ onStart });
|
|
318
|
+
await fa.generate({ x: 5 });
|
|
319
|
+
|
|
320
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
321
|
+
const firstCall = onStart.mock.calls[0];
|
|
322
|
+
if (!firstCall) throw new Error("Expected onStart first call");
|
|
323
|
+
expect(firstCall[0]).toEqual({ input: { x: 5 } });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("fires onFinish hook with input, result, and duration", async () => {
|
|
327
|
+
const onFinish = vi.fn();
|
|
328
|
+
const fa = createSimpleFlowAgent({ onFinish });
|
|
329
|
+
await fa.generate({ x: 3 });
|
|
330
|
+
|
|
331
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
332
|
+
const firstCall = onFinish.mock.calls[0];
|
|
333
|
+
if (!firstCall) throw new Error("Expected onFinish first call");
|
|
334
|
+
const event = firstCall[0];
|
|
335
|
+
expect(event.input).toEqual({ x: 3 });
|
|
336
|
+
expect(event.result).toHaveProperty("output");
|
|
337
|
+
expect(event.result).toHaveProperty("messages");
|
|
338
|
+
expect(event.result).toHaveProperty("usage");
|
|
339
|
+
expect(event.duration).toBeGreaterThanOrEqual(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("fires onError hook when handler throws", async () => {
|
|
343
|
+
const onError = vi.fn();
|
|
344
|
+
const fa = createSimpleFlowAgent({ onError }, async () => {
|
|
345
|
+
throw new Error("boom");
|
|
346
|
+
});
|
|
347
|
+
await fa.generate({ x: 1 });
|
|
348
|
+
|
|
349
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
350
|
+
const firstCall = onError.mock.calls[0];
|
|
351
|
+
if (!firstCall) throw new Error("Expected onError first call");
|
|
352
|
+
expect(firstCall[0].input).toEqual({ x: 1 });
|
|
353
|
+
expect(firstCall[0].error).toBeInstanceOf(Error);
|
|
354
|
+
expect(firstCall[0].error.message).toBe("boom");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("fires both config and override onStart hooks", async () => {
|
|
358
|
+
const configOnStart = vi.fn();
|
|
359
|
+
const overrideOnStart = vi.fn();
|
|
360
|
+
|
|
361
|
+
const fa = createSimpleFlowAgent({ onStart: configOnStart });
|
|
362
|
+
await fa.generate({ x: 1 }, { onStart: overrideOnStart });
|
|
363
|
+
|
|
364
|
+
expect(configOnStart).toHaveBeenCalledTimes(1);
|
|
365
|
+
expect(overrideOnStart).toHaveBeenCalledTimes(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("fires both config and override onFinish hooks", async () => {
|
|
369
|
+
const configOnFinish = vi.fn();
|
|
370
|
+
const overrideOnFinish = vi.fn();
|
|
371
|
+
|
|
372
|
+
const fa = createSimpleFlowAgent({ onFinish: configOnFinish });
|
|
373
|
+
await fa.generate({ x: 1 }, { onFinish: overrideOnFinish });
|
|
374
|
+
|
|
375
|
+
expect(configOnFinish).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(overrideOnFinish).toHaveBeenCalledTimes(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("fires both config and override onError hooks", async () => {
|
|
380
|
+
const configOnError = vi.fn();
|
|
381
|
+
const overrideOnError = vi.fn();
|
|
382
|
+
|
|
383
|
+
const fa = createSimpleFlowAgent({ onError: configOnError }, async () => {
|
|
384
|
+
throw new Error("fail");
|
|
385
|
+
});
|
|
386
|
+
await fa.generate({ x: 1 }, { onError: overrideOnError });
|
|
387
|
+
|
|
388
|
+
expect(configOnError).toHaveBeenCalledTimes(1);
|
|
389
|
+
expect(overrideOnError).toHaveBeenCalledTimes(1);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("does not fire onFinish when handler throws", async () => {
|
|
393
|
+
const onFinish = vi.fn();
|
|
394
|
+
|
|
395
|
+
const fa = createSimpleFlowAgent({ onFinish }, async () => {
|
|
396
|
+
throw new Error("fail");
|
|
397
|
+
});
|
|
398
|
+
await fa.generate({ x: 1 });
|
|
399
|
+
|
|
400
|
+
expect(onFinish).not.toHaveBeenCalled();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("does not fire onError on input validation failure", async () => {
|
|
404
|
+
const onError = vi.fn();
|
|
405
|
+
const fa = createSimpleFlowAgent({ onError });
|
|
406
|
+
|
|
407
|
+
// @ts-expect-error - intentionally invalid input
|
|
408
|
+
await fa.generate({ x: "bad" });
|
|
409
|
+
|
|
410
|
+
expect(onError).not.toHaveBeenCalled();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// generate() — hook resilience
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
describe("generate() hook resilience", () => {
|
|
419
|
+
it("onStart throwing does not prevent execution", async () => {
|
|
420
|
+
const fa = createSimpleFlowAgent({
|
|
421
|
+
onStart: () => {
|
|
422
|
+
throw new Error("onStart boom");
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await fa.generate({ x: 5 });
|
|
427
|
+
|
|
428
|
+
expect(result.ok).toBe(true);
|
|
429
|
+
if (!result.ok) return;
|
|
430
|
+
expect(result.output).toEqual({ y: 10 });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("onFinish throwing does not break the result", async () => {
|
|
434
|
+
const fa = createSimpleFlowAgent({
|
|
435
|
+
onFinish: () => {
|
|
436
|
+
throw new Error("onFinish boom");
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const result = await fa.generate({ x: 5 });
|
|
441
|
+
|
|
442
|
+
expect(result.ok).toBe(true);
|
|
443
|
+
if (!result.ok) return;
|
|
444
|
+
expect(result.output).toEqual({ y: 10 });
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("onError throwing does not break the error result", async () => {
|
|
448
|
+
const fa = createSimpleFlowAgent(
|
|
449
|
+
{
|
|
450
|
+
onError: () => {
|
|
451
|
+
throw new Error("onError boom");
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
async () => {
|
|
455
|
+
throw new Error("handler fail");
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const result = await fa.generate({ x: 1 });
|
|
460
|
+
|
|
461
|
+
expect(result.ok).toBe(false);
|
|
462
|
+
if (result.ok) return;
|
|
463
|
+
expect(result.error.code).toBe("FLOW_AGENT_ERROR");
|
|
464
|
+
expect(result.error.message).toBe("handler fail");
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// generate() — overrides
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
describe("generate() overrides", () => {
|
|
473
|
+
it("uses override signal when provided", async () => {
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const fa = createSimpleFlowAgent();
|
|
476
|
+
const result = await fa.generate({ x: 1 }, { signal: controller.signal });
|
|
477
|
+
|
|
478
|
+
expect(result.ok).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("uses override logger when provided", async () => {
|
|
482
|
+
const overrideLogger = createMockLogger();
|
|
483
|
+
const fa = createSimpleFlowAgent();
|
|
484
|
+
await fa.generate({ x: 1 }, { logger: overrideLogger });
|
|
485
|
+
|
|
486
|
+
expect(overrideLogger.child).toHaveBeenCalledWith({ flowAgentId: "test-flow" });
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// generate() — void output (no output schema)
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
describe("generate() void output", () => {
|
|
495
|
+
it("collects text from messages when no output schema is defined", async () => {
|
|
496
|
+
const fa = flowAgent<{ x: number }>(
|
|
497
|
+
{
|
|
498
|
+
name: "void-gen",
|
|
499
|
+
input: Input,
|
|
500
|
+
logger: createMockLogger(),
|
|
501
|
+
},
|
|
502
|
+
async ({ input, $ }) => {
|
|
503
|
+
await $.step({
|
|
504
|
+
id: "compute",
|
|
505
|
+
execute: async () => `result: ${input.x * 2}`,
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const result = await fa.generate({ x: 5 });
|
|
511
|
+
|
|
512
|
+
expect(result.ok).toBe(true);
|
|
513
|
+
if (!result.ok) return;
|
|
514
|
+
expect(typeof result.output).toBe("string");
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// stream() — success path
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
describe("stream() success", () => {
|
|
523
|
+
it("returns ok: true with fullStream, output, messages, usage, and finishReason", async () => {
|
|
524
|
+
const fa = createSimpleFlowAgent();
|
|
525
|
+
const result = await fa.stream({ x: 5 });
|
|
526
|
+
|
|
527
|
+
expect(result.ok).toBe(true);
|
|
528
|
+
if (!result.ok) return;
|
|
529
|
+
expect(result.fullStream).toBeInstanceOf(ReadableStream);
|
|
530
|
+
expect(result.output).toBeInstanceOf(Promise);
|
|
531
|
+
expect(result.messages).toBeInstanceOf(Promise);
|
|
532
|
+
expect(result.usage).toBeInstanceOf(Promise);
|
|
533
|
+
expect(result.finishReason).toBeInstanceOf(Promise);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("output promise resolves to computed output", async () => {
|
|
537
|
+
const fa = createSimpleFlowAgent();
|
|
538
|
+
const result = await fa.stream({ x: 4 });
|
|
539
|
+
|
|
540
|
+
expect(result.ok).toBe(true);
|
|
541
|
+
if (!result.ok) return;
|
|
542
|
+
|
|
543
|
+
// Drain the stream
|
|
544
|
+
const reader = result.fullStream.getReader();
|
|
545
|
+
for (;;) {
|
|
546
|
+
const { done } = await reader.read();
|
|
547
|
+
if (done) break;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const output = await result.output;
|
|
551
|
+
expect(output).toEqual({ y: 8 });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("fullStream emits a finish event on success", async () => {
|
|
555
|
+
const fa = createSimpleFlowAgent();
|
|
556
|
+
const result = await fa.stream({ x: 2 });
|
|
557
|
+
|
|
558
|
+
expect(result.ok).toBe(true);
|
|
559
|
+
if (!result.ok) return;
|
|
560
|
+
|
|
561
|
+
const parts: unknown[] = [];
|
|
562
|
+
const reader = result.fullStream.getReader();
|
|
563
|
+
for (;;) {
|
|
564
|
+
const { done, value } = await reader.read();
|
|
565
|
+
if (done) break;
|
|
566
|
+
parts.push(value);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Last part should be a finish event
|
|
570
|
+
expect(parts.length).toBeGreaterThanOrEqual(1);
|
|
571
|
+
const lastPart = parts[parts.length - 1] as Record<string, unknown>;
|
|
572
|
+
expect(lastPart.type).toBe("finish");
|
|
573
|
+
expect(lastPart.finishReason).toBe("stop");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("messages promise resolves after stream completes", async () => {
|
|
577
|
+
const fa = createSimpleFlowAgent();
|
|
578
|
+
const result = await fa.stream({ x: 1 });
|
|
579
|
+
|
|
580
|
+
expect(result.ok).toBe(true);
|
|
581
|
+
if (!result.ok) return;
|
|
582
|
+
|
|
583
|
+
// Drain the stream
|
|
584
|
+
const reader = result.fullStream.getReader();
|
|
585
|
+
for (;;) {
|
|
586
|
+
const { done } = await reader.read();
|
|
587
|
+
if (done) break;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const messages = await result.messages;
|
|
591
|
+
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
592
|
+
expect(messages[0]?.role).toBe("user");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("usage promise resolves with zero-valued fields when no sub-agents", async () => {
|
|
596
|
+
const fa = createSimpleFlowAgent();
|
|
597
|
+
const result = await fa.stream({ x: 1 });
|
|
598
|
+
|
|
599
|
+
expect(result.ok).toBe(true);
|
|
600
|
+
if (!result.ok) return;
|
|
601
|
+
|
|
602
|
+
// Drain
|
|
603
|
+
const reader = result.fullStream.getReader();
|
|
604
|
+
for (;;) {
|
|
605
|
+
const { done } = await reader.read();
|
|
606
|
+
if (done) break;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const usage = await result.usage;
|
|
610
|
+
expect(usage).toEqual({
|
|
611
|
+
inputTokens: 0,
|
|
612
|
+
outputTokens: 0,
|
|
613
|
+
totalTokens: 0,
|
|
614
|
+
cacheReadTokens: 0,
|
|
615
|
+
cacheWriteTokens: 0,
|
|
616
|
+
reasoningTokens: 0,
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("finishReason promise resolves to stop", async () => {
|
|
621
|
+
const fa = createSimpleFlowAgent();
|
|
622
|
+
const result = await fa.stream({ x: 1 });
|
|
623
|
+
|
|
624
|
+
expect(result.ok).toBe(true);
|
|
625
|
+
if (!result.ok) return;
|
|
626
|
+
|
|
627
|
+
// Drain
|
|
628
|
+
const reader = result.fullStream.getReader();
|
|
629
|
+
for (;;) {
|
|
630
|
+
const { done } = await reader.read();
|
|
631
|
+
if (done) break;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const finishReason = await result.finishReason;
|
|
635
|
+
expect(finishReason).toBe("stop");
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// stream() — with steps
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
describe("stream() with steps", () => {
|
|
644
|
+
it("emits typed tool-call and tool-result events through fullStream", async () => {
|
|
645
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
646
|
+
{
|
|
647
|
+
name: "stream-step-flow",
|
|
648
|
+
input: Input,
|
|
649
|
+
output: Output,
|
|
650
|
+
logger: createMockLogger(),
|
|
651
|
+
},
|
|
652
|
+
async ({ input, $ }) => {
|
|
653
|
+
await $.step({
|
|
654
|
+
id: "compute",
|
|
655
|
+
execute: async () => input.x + 1,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
return { y: input.x + 1 };
|
|
659
|
+
},
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
const result = await fa.stream({ x: 5 });
|
|
663
|
+
|
|
664
|
+
expect(result.ok).toBe(true);
|
|
665
|
+
if (!result.ok) return;
|
|
666
|
+
|
|
667
|
+
const parts: Record<string, unknown>[] = [];
|
|
668
|
+
const reader = result.fullStream.getReader();
|
|
669
|
+
for (;;) {
|
|
670
|
+
const { done, value } = await reader.read();
|
|
671
|
+
if (done) break;
|
|
672
|
+
parts.push(value as Record<string, unknown>);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Should have tool-call event, tool-result event, and finish event
|
|
676
|
+
expect(parts.length).toBeGreaterThanOrEqual(3);
|
|
677
|
+
|
|
678
|
+
const toolCallPart = parts.find((p) => p.type === "tool-call");
|
|
679
|
+
expect(toolCallPart).toBeDefined();
|
|
680
|
+
expect(toolCallPart?.toolName).toBe("compute");
|
|
681
|
+
|
|
682
|
+
const toolResultPart = parts.find((p) => p.type === "tool-result");
|
|
683
|
+
expect(toolResultPart).toBeDefined();
|
|
684
|
+
expect(toolResultPart?.toolName).toBe("compute");
|
|
685
|
+
expect(toolResultPart?.output).toBe(6);
|
|
686
|
+
|
|
687
|
+
const finishPart = parts.find((p) => p.type === "finish");
|
|
688
|
+
expect(finishPart).toBeDefined();
|
|
689
|
+
expect(finishPart?.finishReason).toBe("stop");
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// stream() — input validation
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
describe("stream() input validation", () => {
|
|
698
|
+
it("returns VALIDATION_ERROR when input fails safeParse", async () => {
|
|
699
|
+
const fa = createSimpleFlowAgent();
|
|
700
|
+
|
|
701
|
+
// @ts-expect-error - intentionally invalid input
|
|
702
|
+
const result = await fa.stream({ x: "not-a-number" });
|
|
703
|
+
|
|
704
|
+
expect(result.ok).toBe(false);
|
|
705
|
+
if (result.ok) return;
|
|
706
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
707
|
+
expect(result.error.message).toContain("Input validation failed");
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
// stream() — error handling
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
describe("stream() error handling", () => {
|
|
716
|
+
it("stream closes and output promise rejects when handler throws", async () => {
|
|
717
|
+
const fa = createSimpleFlowAgent(undefined, async () => {
|
|
718
|
+
throw new Error("stream handler fail");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const result = await fa.stream({ x: 1 });
|
|
722
|
+
|
|
723
|
+
expect(result.ok).toBe(true);
|
|
724
|
+
if (!result.ok) return;
|
|
725
|
+
|
|
726
|
+
// Suppress all derived promise rejections to avoid unhandled rejection noise
|
|
727
|
+
result.messages.catch(() => {});
|
|
728
|
+
result.usage.catch(() => {});
|
|
729
|
+
result.finishReason.catch(() => {});
|
|
730
|
+
|
|
731
|
+
// Drain the stream (should close after error)
|
|
732
|
+
const reader = result.fullStream.getReader();
|
|
733
|
+
for (;;) {
|
|
734
|
+
const { done } = await reader.read();
|
|
735
|
+
if (done) break;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
await expect(result.output).rejects.toThrow("stream handler fail");
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("writes error event and closes stream when handler throws", async () => {
|
|
742
|
+
const fa = createSimpleFlowAgent(undefined, async () => {
|
|
743
|
+
throw new Error("stream error test");
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const result = await fa.stream({ x: 1 });
|
|
747
|
+
|
|
748
|
+
expect(result.ok).toBe(true);
|
|
749
|
+
if (!result.ok) return;
|
|
750
|
+
|
|
751
|
+
// Suppress derived promise rejections
|
|
752
|
+
result.messages.catch(() => {});
|
|
753
|
+
result.usage.catch(() => {});
|
|
754
|
+
result.finishReason.catch(() => {});
|
|
755
|
+
|
|
756
|
+
// Drain the stream and collect events
|
|
757
|
+
const parts: Record<string, unknown>[] = [];
|
|
758
|
+
const reader = result.fullStream.getReader();
|
|
759
|
+
for (;;) {
|
|
760
|
+
const { done, value } = await reader.read();
|
|
761
|
+
if (done) break;
|
|
762
|
+
parts.push(value as Record<string, unknown>);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Should have an error event in the stream
|
|
766
|
+
const errorPart = parts.find((p) => p.type === "error");
|
|
767
|
+
expect(errorPart).toBeDefined();
|
|
768
|
+
|
|
769
|
+
// Output should reject
|
|
770
|
+
await expect(result.output).rejects.toThrow("stream error test");
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
// stream() — output validation
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
describe("stream() output validation", () => {
|
|
779
|
+
it("rejects output promise with Output validation failed when handler returns invalid data", async () => {
|
|
780
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
781
|
+
{
|
|
782
|
+
name: "test",
|
|
783
|
+
input: Input,
|
|
784
|
+
output: Output,
|
|
785
|
+
logger: createMockLogger(),
|
|
786
|
+
},
|
|
787
|
+
async () => ({ y: "not-a-number" }) as unknown as { y: number },
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
const result = await fa.stream({ x: 1 });
|
|
791
|
+
|
|
792
|
+
expect(result.ok).toBe(true);
|
|
793
|
+
if (!result.ok) return;
|
|
794
|
+
|
|
795
|
+
// Suppress derived promise rejections
|
|
796
|
+
result.messages.catch(() => {});
|
|
797
|
+
result.usage.catch(() => {});
|
|
798
|
+
result.finishReason.catch(() => {});
|
|
799
|
+
|
|
800
|
+
// Drain the stream
|
|
801
|
+
const reader = result.fullStream.getReader();
|
|
802
|
+
for (;;) {
|
|
803
|
+
const { done } = await reader.read();
|
|
804
|
+
if (done) break;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
await expect(result.output).rejects.toThrow("Output validation failed");
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// stream() — hooks
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
describe("stream() hooks", () => {
|
|
816
|
+
it("fires onStart hook with input", async () => {
|
|
817
|
+
const onStart = vi.fn();
|
|
818
|
+
const fa = createSimpleFlowAgent({ onStart });
|
|
819
|
+
await fa.stream({ x: 5 });
|
|
820
|
+
|
|
821
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
822
|
+
const firstCall = onStart.mock.calls[0];
|
|
823
|
+
if (!firstCall) throw new Error("Expected onStart first call");
|
|
824
|
+
expect(firstCall[0]).toEqual({ input: { x: 5 } });
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("fires onFinish hook after stream completes", async () => {
|
|
828
|
+
const onFinish = vi.fn();
|
|
829
|
+
const fa = createSimpleFlowAgent({ onFinish });
|
|
830
|
+
const result = await fa.stream({ x: 3 });
|
|
831
|
+
|
|
832
|
+
expect(result.ok).toBe(true);
|
|
833
|
+
if (!result.ok) return;
|
|
834
|
+
|
|
835
|
+
// Drain
|
|
836
|
+
const reader = result.fullStream.getReader();
|
|
837
|
+
for (;;) {
|
|
838
|
+
const { done } = await reader.read();
|
|
839
|
+
if (done) break;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Wait for output to settle (which means onFinish has fired)
|
|
843
|
+
await result.output;
|
|
844
|
+
|
|
845
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("fires both config and override onStart hooks during stream", async () => {
|
|
849
|
+
const configOnStart = vi.fn();
|
|
850
|
+
const overrideOnStart = vi.fn();
|
|
851
|
+
|
|
852
|
+
const fa = createSimpleFlowAgent({ onStart: configOnStart });
|
|
853
|
+
const result = await fa.stream({ x: 7 }, { onStart: overrideOnStart });
|
|
854
|
+
|
|
855
|
+
expect(result.ok).toBe(true);
|
|
856
|
+
if (!result.ok) return;
|
|
857
|
+
|
|
858
|
+
// Drain the stream
|
|
859
|
+
const reader = result.fullStream.getReader();
|
|
860
|
+
for (;;) {
|
|
861
|
+
const { done } = await reader.read();
|
|
862
|
+
if (done) break;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
await result.output;
|
|
866
|
+
|
|
867
|
+
expect(configOnStart).toHaveBeenCalledTimes(1);
|
|
868
|
+
expect(overrideOnStart).toHaveBeenCalledTimes(1);
|
|
869
|
+
const configCall = configOnStart.mock.calls[0];
|
|
870
|
+
const overrideCall = overrideOnStart.mock.calls[0];
|
|
871
|
+
if (!configCall) throw new Error("Expected configOnStart first call");
|
|
872
|
+
if (!overrideCall) throw new Error("Expected overrideOnStart first call");
|
|
873
|
+
expect(configCall[0]).toEqual({ input: { x: 7 } });
|
|
874
|
+
expect(overrideCall[0]).toEqual({ input: { x: 7 } });
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("fires onError hook when handler throws during stream", async () => {
|
|
878
|
+
const onError = vi.fn();
|
|
879
|
+
const fa = createSimpleFlowAgent({ onError }, async () => {
|
|
880
|
+
throw new Error("stream boom");
|
|
881
|
+
});
|
|
882
|
+
const result = await fa.stream({ x: 1 });
|
|
883
|
+
|
|
884
|
+
expect(result.ok).toBe(true);
|
|
885
|
+
if (!result.ok) return;
|
|
886
|
+
|
|
887
|
+
// Suppress all derived promise rejections
|
|
888
|
+
result.messages.catch(() => {});
|
|
889
|
+
result.usage.catch(() => {});
|
|
890
|
+
result.finishReason.catch(() => {});
|
|
891
|
+
|
|
892
|
+
// Drain
|
|
893
|
+
const reader = result.fullStream.getReader();
|
|
894
|
+
for (;;) {
|
|
895
|
+
const { done } = await reader.read();
|
|
896
|
+
if (done) break;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Wait for the error to settle
|
|
900
|
+
await result.output.catch(() => {});
|
|
901
|
+
|
|
902
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// stream() — void output (no output schema)
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
describe("stream() void output", () => {
|
|
911
|
+
it("collects text from messages when no output schema is defined", async () => {
|
|
912
|
+
const fa = flowAgent<{ x: number }>(
|
|
913
|
+
{
|
|
914
|
+
name: "void-flow",
|
|
915
|
+
input: Input,
|
|
916
|
+
logger: createMockLogger(),
|
|
917
|
+
},
|
|
918
|
+
async ({ input, $ }) => {
|
|
919
|
+
await $.step({
|
|
920
|
+
id: "compute",
|
|
921
|
+
execute: async () => `result: ${input.x * 2}`,
|
|
922
|
+
});
|
|
923
|
+
},
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
const result = await fa.stream({ x: 5 });
|
|
927
|
+
|
|
928
|
+
expect(result.ok).toBe(true);
|
|
929
|
+
if (!result.ok) return;
|
|
930
|
+
|
|
931
|
+
// Drain the stream
|
|
932
|
+
const reader = result.fullStream.getReader();
|
|
933
|
+
for (;;) {
|
|
934
|
+
const { done } = await reader.read();
|
|
935
|
+
if (done) break;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const output = await result.output;
|
|
939
|
+
expect(typeof output).toBe("string");
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
// fn() — delegates to generate()
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
|
|
947
|
+
describe("fn()", () => {
|
|
948
|
+
it("returns a function that delegates to generate()", async () => {
|
|
949
|
+
const fa = createSimpleFlowAgent();
|
|
950
|
+
const fn = fa.fn();
|
|
951
|
+
|
|
952
|
+
const result = await fn({ x: 6 });
|
|
953
|
+
|
|
954
|
+
expect(result.ok).toBe(true);
|
|
955
|
+
if (!result.ok) return;
|
|
956
|
+
expect(result.output).toEqual({ y: 12 });
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("fn() passes overrides through to generate", async () => {
|
|
960
|
+
const onStart = vi.fn();
|
|
961
|
+
const fa = createSimpleFlowAgent();
|
|
962
|
+
const fn = fa.fn();
|
|
963
|
+
|
|
964
|
+
await fn({ x: 1 }, { onStart });
|
|
965
|
+
|
|
966
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("fn() handles validation errors", async () => {
|
|
970
|
+
const fa = createSimpleFlowAgent();
|
|
971
|
+
const fn = fa.fn();
|
|
972
|
+
|
|
973
|
+
// @ts-expect-error - intentionally invalid input
|
|
974
|
+
const result = await fn({ x: "bad" });
|
|
975
|
+
|
|
976
|
+
expect(result.ok).toBe(false);
|
|
977
|
+
if (result.ok) return;
|
|
978
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// ---------------------------------------------------------------------------
|
|
983
|
+
// Edge cases
|
|
984
|
+
// ---------------------------------------------------------------------------
|
|
985
|
+
|
|
986
|
+
describe("edge cases", () => {
|
|
987
|
+
it("handles undefined overrides gracefully", async () => {
|
|
988
|
+
const fa = createSimpleFlowAgent();
|
|
989
|
+
const result = await fa.generate({ x: 1 }, undefined);
|
|
990
|
+
|
|
991
|
+
expect(result.ok).toBe(true);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("uses default logger when none provided", async () => {
|
|
995
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
996
|
+
{
|
|
997
|
+
name: "no-logger-flow",
|
|
998
|
+
input: Input,
|
|
999
|
+
output: Output,
|
|
1000
|
+
},
|
|
1001
|
+
async ({ input }) => ({ y: input.x }),
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
const result = await fa.generate({ x: 1 });
|
|
1005
|
+
expect(result.ok).toBe(true);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("handler receives scoped logger", async () => {
|
|
1009
|
+
let receivedLog: unknown;
|
|
1010
|
+
const fa = flowAgent<{ x: number }, { y: number }>(
|
|
1011
|
+
{
|
|
1012
|
+
name: "log-flow",
|
|
1013
|
+
input: Input,
|
|
1014
|
+
output: Output,
|
|
1015
|
+
logger: createMockLogger(),
|
|
1016
|
+
},
|
|
1017
|
+
async ({ input, log }) => {
|
|
1018
|
+
receivedLog = log;
|
|
1019
|
+
return { y: input.x };
|
|
1020
|
+
},
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
await fa.generate({ x: 1 });
|
|
1024
|
+
|
|
1025
|
+
expect(receivedLog).toBeDefined();
|
|
1026
|
+
});
|
|
1027
|
+
});
|