@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.
Files changed (153) hide show
  1. package/.generated/req.txt +1 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/.turbo/turbo-test$colon$coverage.log +109 -0
  4. package/.turbo/turbo-test.log +141 -0
  5. package/.turbo/turbo-typecheck.log +4 -0
  6. package/CHANGELOG.md +16 -0
  7. package/ISSUES.md +540 -0
  8. package/LICENSE +21 -0
  9. package/README.md +128 -0
  10. package/banner.svg +97 -0
  11. package/coverage/lcov-report/base.css +224 -0
  12. package/coverage/lcov-report/block-navigation.js +87 -0
  13. package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
  14. package/coverage/lcov-report/core/agents/base/index.html +146 -0
  15. package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
  16. package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
  17. package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
  18. package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
  19. package/coverage/lcov-report/core/agents/flow/index.html +146 -0
  20. package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
  21. package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
  22. package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
  23. package/coverage/lcov-report/core/index.html +131 -0
  24. package/coverage/lcov-report/core/logger.ts.html +541 -0
  25. package/coverage/lcov-report/core/models/providers/index.html +116 -0
  26. package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
  27. package/coverage/lcov-report/core/provider/index.html +131 -0
  28. package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
  29. package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
  30. package/coverage/lcov-report/core/tool.ts.html +577 -0
  31. package/coverage/lcov-report/favicon.png +0 -0
  32. package/coverage/lcov-report/index.html +221 -0
  33. package/coverage/lcov-report/lib/hooks.ts.html +262 -0
  34. package/coverage/lcov-report/lib/index.html +161 -0
  35. package/coverage/lcov-report/lib/middleware.ts.html +274 -0
  36. package/coverage/lcov-report/lib/runnable.ts.html +151 -0
  37. package/coverage/lcov-report/lib/trace.ts.html +520 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/utils/attempt.ts.html +199 -0
  43. package/coverage/lcov-report/utils/error.ts.html +421 -0
  44. package/coverage/lcov-report/utils/index.html +176 -0
  45. package/coverage/lcov-report/utils/resolve.ts.html +208 -0
  46. package/coverage/lcov-report/utils/result.ts.html +538 -0
  47. package/coverage/lcov-report/utils/zod.ts.html +178 -0
  48. package/coverage/lcov.info +1566 -0
  49. package/dist/index.d.mts +2883 -0
  50. package/dist/index.d.mts.map +1 -0
  51. package/dist/index.mjs +2312 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/docs/core/agent.md +231 -0
  54. package/docs/core/hooks.md +95 -0
  55. package/docs/core/overview.md +87 -0
  56. package/docs/core/step.md +279 -0
  57. package/docs/core/tools.md +98 -0
  58. package/docs/core/workflow.md +235 -0
  59. package/docs/guides/create-agent.md +224 -0
  60. package/docs/guides/create-tool.md +137 -0
  61. package/docs/guides/create-workflow.md +374 -0
  62. package/docs/overview.md +244 -0
  63. package/docs/provider/models.md +55 -0
  64. package/docs/provider/overview.md +106 -0
  65. package/docs/provider/usage.md +100 -0
  66. package/docs/research/experimental-context.md +167 -0
  67. package/docs/research/gap-analysis.md +86 -0
  68. package/docs/research/prepare-step-and-active-tools.md +138 -0
  69. package/docs/research/sub-agent-model.md +249 -0
  70. package/docs/troubleshooting.md +60 -0
  71. package/logo.svg +17 -0
  72. package/models.config.json +18 -0
  73. package/package.json +60 -0
  74. package/scripts/generate-models.ts +324 -0
  75. package/src/core/agents/base/agent.test.ts +1522 -0
  76. package/src/core/agents/base/agent.ts +547 -0
  77. package/src/core/agents/base/output.test.ts +93 -0
  78. package/src/core/agents/base/output.ts +57 -0
  79. package/src/core/agents/base/types.test-d.ts +69 -0
  80. package/src/core/agents/base/types.ts +503 -0
  81. package/src/core/agents/base/utils.test.ts +397 -0
  82. package/src/core/agents/base/utils.ts +197 -0
  83. package/src/core/agents/flow/engine.test.ts +452 -0
  84. package/src/core/agents/flow/engine.ts +281 -0
  85. package/src/core/agents/flow/flow-agent.test.ts +1027 -0
  86. package/src/core/agents/flow/flow-agent.ts +473 -0
  87. package/src/core/agents/flow/messages.test.ts +198 -0
  88. package/src/core/agents/flow/messages.ts +141 -0
  89. package/src/core/agents/flow/steps/agent.test.ts +280 -0
  90. package/src/core/agents/flow/steps/agent.ts +87 -0
  91. package/src/core/agents/flow/steps/all.test.ts +300 -0
  92. package/src/core/agents/flow/steps/all.ts +73 -0
  93. package/src/core/agents/flow/steps/builder.ts +124 -0
  94. package/src/core/agents/flow/steps/each.test.ts +257 -0
  95. package/src/core/agents/flow/steps/each.ts +61 -0
  96. package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
  97. package/src/core/agents/flow/steps/factory.test.ts +1025 -0
  98. package/src/core/agents/flow/steps/factory.ts +645 -0
  99. package/src/core/agents/flow/steps/map.test.ts +273 -0
  100. package/src/core/agents/flow/steps/map.ts +75 -0
  101. package/src/core/agents/flow/steps/race.test.ts +290 -0
  102. package/src/core/agents/flow/steps/race.ts +59 -0
  103. package/src/core/agents/flow/steps/reduce.test.ts +310 -0
  104. package/src/core/agents/flow/steps/reduce.ts +73 -0
  105. package/src/core/agents/flow/steps/result.ts +27 -0
  106. package/src/core/agents/flow/steps/step.test.ts +402 -0
  107. package/src/core/agents/flow/steps/step.ts +51 -0
  108. package/src/core/agents/flow/steps/while.test.ts +283 -0
  109. package/src/core/agents/flow/steps/while.ts +75 -0
  110. package/src/core/agents/flow/types.ts +348 -0
  111. package/src/core/logger.test.ts +163 -0
  112. package/src/core/logger.ts +152 -0
  113. package/src/core/models/index.test.ts +137 -0
  114. package/src/core/models/index.ts +152 -0
  115. package/src/core/models/providers/openai.ts +84 -0
  116. package/src/core/provider/provider.test.ts +128 -0
  117. package/src/core/provider/provider.ts +99 -0
  118. package/src/core/provider/types.ts +98 -0
  119. package/src/core/provider/usage.test.ts +304 -0
  120. package/src/core/provider/usage.ts +97 -0
  121. package/src/core/tool.test.ts +65 -0
  122. package/src/core/tool.ts +164 -0
  123. package/src/core/types.ts +66 -0
  124. package/src/index.ts +95 -0
  125. package/src/lib/context.test.ts +86 -0
  126. package/src/lib/context.ts +49 -0
  127. package/src/lib/hooks.test.ts +102 -0
  128. package/src/lib/hooks.ts +59 -0
  129. package/src/lib/middleware.test.ts +122 -0
  130. package/src/lib/middleware.ts +63 -0
  131. package/src/lib/runnable.test.ts +41 -0
  132. package/src/lib/runnable.ts +22 -0
  133. package/src/lib/trace.test.ts +291 -0
  134. package/src/lib/trace.ts +145 -0
  135. package/src/models/index.ts +123 -0
  136. package/src/models/providers/index.ts +15 -0
  137. package/src/models/providers/openai.ts +84 -0
  138. package/src/testing/context.ts +32 -0
  139. package/src/testing/index.ts +2 -0
  140. package/src/testing/logger.ts +19 -0
  141. package/src/utils/attempt.test.ts +127 -0
  142. package/src/utils/attempt.ts +38 -0
  143. package/src/utils/error.test.ts +179 -0
  144. package/src/utils/error.ts +112 -0
  145. package/src/utils/resolve.test.ts +38 -0
  146. package/src/utils/resolve.ts +41 -0
  147. package/src/utils/result.test.ts +79 -0
  148. package/src/utils/result.ts +151 -0
  149. package/src/utils/zod.test.ts +69 -0
  150. package/src/utils/zod.ts +31 -0
  151. package/tsconfig.json +25 -0
  152. package/tsdown.config.ts +15 -0
  153. 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
+ });