@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,1025 @@
|
|
|
1
|
+
import { match } from "ts-pattern";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { Agent, GenerateResult, StreamPart } from "@/core/agents/base/types.js";
|
|
5
|
+
import { createStepBuilder } from "@/core/agents/flow/steps/factory.js";
|
|
6
|
+
import { createMockCtx } from "@/testing/index.js";
|
|
7
|
+
import type { Result } from "@/utils/result.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// step() — the core primitive
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("step()", () => {
|
|
14
|
+
it("returns ok: true with value on success", async () => {
|
|
15
|
+
const ctx = createMockCtx();
|
|
16
|
+
const $ = createStepBuilder({ ctx });
|
|
17
|
+
|
|
18
|
+
const result = await $.step({
|
|
19
|
+
id: "greet",
|
|
20
|
+
execute: async () => ({ greeting: "hello" }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.ok).toBe(true);
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
expect(result.value).toEqual({ greeting: "hello" });
|
|
28
|
+
expect(result.step.id).toBe("greet");
|
|
29
|
+
expect(result.step.type).toBe("step");
|
|
30
|
+
expect(result.step.index).toBe(0);
|
|
31
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns ok: false with StepError on thrown error", async () => {
|
|
35
|
+
const ctx = createMockCtx();
|
|
36
|
+
const $ = createStepBuilder({ ctx });
|
|
37
|
+
|
|
38
|
+
const result = await $.step({
|
|
39
|
+
id: "fail",
|
|
40
|
+
execute: async () => {
|
|
41
|
+
throw new Error("boom");
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.ok).toBe(false);
|
|
46
|
+
if (result.ok) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
expect(result.error.code).toBe("STEP_ERROR");
|
|
50
|
+
expect(result.error.message).toBe("boom");
|
|
51
|
+
expect(result.error.stepId).toBe("fail");
|
|
52
|
+
expect(result.error.cause).toBeInstanceOf(Error);
|
|
53
|
+
expect(result.step.id).toBe("fail");
|
|
54
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("fires hooks in order: onStart → execute → onFinish", async () => {
|
|
58
|
+
const order: string[] = [];
|
|
59
|
+
const ctx = createMockCtx();
|
|
60
|
+
const $ = createStepBuilder({ ctx });
|
|
61
|
+
|
|
62
|
+
await $.step({
|
|
63
|
+
id: "ordered",
|
|
64
|
+
onStart: () => {
|
|
65
|
+
order.push("onStart");
|
|
66
|
+
},
|
|
67
|
+
execute: async () => {
|
|
68
|
+
order.push("execute");
|
|
69
|
+
return { v: 1 };
|
|
70
|
+
},
|
|
71
|
+
onFinish: () => {
|
|
72
|
+
order.push("onFinish");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(order).toEqual(["onStart", "execute", "onFinish"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("fires onError instead of onFinish on failure", async () => {
|
|
80
|
+
const order: string[] = [];
|
|
81
|
+
const ctx = createMockCtx();
|
|
82
|
+
const $ = createStepBuilder({ ctx });
|
|
83
|
+
|
|
84
|
+
await $.step({
|
|
85
|
+
id: "err-hooks",
|
|
86
|
+
onStart: () => {
|
|
87
|
+
order.push("onStart");
|
|
88
|
+
},
|
|
89
|
+
execute: async () => {
|
|
90
|
+
order.push("execute");
|
|
91
|
+
throw new Error("fail");
|
|
92
|
+
},
|
|
93
|
+
onFinish: () => {
|
|
94
|
+
order.push("onFinish");
|
|
95
|
+
},
|
|
96
|
+
onError: () => {
|
|
97
|
+
order.push("onError");
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(order).toEqual(["onStart", "execute", "onError"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("fires parent hooks at correct points", async () => {
|
|
105
|
+
const order: string[] = [];
|
|
106
|
+
const ctx = createMockCtx();
|
|
107
|
+
const $ = createStepBuilder({
|
|
108
|
+
ctx,
|
|
109
|
+
parentHooks: {
|
|
110
|
+
onStepStart: () => {
|
|
111
|
+
order.push("parentStart");
|
|
112
|
+
},
|
|
113
|
+
onStepFinish: () => {
|
|
114
|
+
order.push("parentFinish");
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await $.step({
|
|
120
|
+
id: "parent-hooks",
|
|
121
|
+
onStart: () => {
|
|
122
|
+
order.push("onStart");
|
|
123
|
+
},
|
|
124
|
+
execute: async () => {
|
|
125
|
+
order.push("execute");
|
|
126
|
+
return { v: 1 };
|
|
127
|
+
},
|
|
128
|
+
onFinish: () => {
|
|
129
|
+
order.push("onFinish");
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(order).toEqual(["onStart", "parentStart", "execute", "onFinish", "parentFinish"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("parentHooks.onStepFinish fires on error with result undefined", async () => {
|
|
137
|
+
const parentFinish = vi.fn();
|
|
138
|
+
const ctx = createMockCtx();
|
|
139
|
+
const $ = createStepBuilder({
|
|
140
|
+
ctx,
|
|
141
|
+
parentHooks: {
|
|
142
|
+
onStepFinish: parentFinish,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await $.step({
|
|
147
|
+
id: "parent-finish-on-error",
|
|
148
|
+
execute: async () => {
|
|
149
|
+
throw new Error("fail");
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(parentFinish).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(parentFinish).toHaveBeenCalledWith(
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
step: expect.objectContaining({ id: "parent-finish-on-error" }),
|
|
157
|
+
result: undefined,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("fireHooks swallows hook errors without breaking execution and logs warning", async () => {
|
|
163
|
+
const ctx = createMockCtx();
|
|
164
|
+
const $ = createStepBuilder({ ctx });
|
|
165
|
+
|
|
166
|
+
const result = await $.step({
|
|
167
|
+
id: "safe",
|
|
168
|
+
onStart: () => {
|
|
169
|
+
throw new Error("hook boom");
|
|
170
|
+
},
|
|
171
|
+
execute: async () => ({ value: 42 }),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.ok).toBe(true);
|
|
175
|
+
if (!result.ok) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
expect(result.value).toEqual({ value: 42 });
|
|
179
|
+
expect(ctx.log.warn).toHaveBeenCalledWith("hook error", { error: "hook boom" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("registers trace entries", async () => {
|
|
183
|
+
const ctx = createMockCtx();
|
|
184
|
+
const $ = createStepBuilder({ ctx });
|
|
185
|
+
|
|
186
|
+
await $.step({
|
|
187
|
+
id: "traced",
|
|
188
|
+
execute: async () => ({ v: 1 }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(ctx.trace).toHaveLength(1);
|
|
192
|
+
const traceEntry = ctx.trace[0];
|
|
193
|
+
if (traceEntry === undefined) {
|
|
194
|
+
throw new Error("Expected trace entry");
|
|
195
|
+
}
|
|
196
|
+
expect(traceEntry.id).toBe("traced");
|
|
197
|
+
expect(traceEntry.type).toBe("step");
|
|
198
|
+
expect(traceEntry.output).toEqual({ v: 1 });
|
|
199
|
+
expect(traceEntry.finishedAt).toBeGreaterThan(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("records trace error on failure", async () => {
|
|
203
|
+
const ctx = createMockCtx();
|
|
204
|
+
const $ = createStepBuilder({ ctx });
|
|
205
|
+
|
|
206
|
+
await $.step({
|
|
207
|
+
id: "trace-err",
|
|
208
|
+
execute: async () => {
|
|
209
|
+
throw new Error("trace-boom");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const traceEntry = ctx.trace[0];
|
|
214
|
+
if (traceEntry === undefined) {
|
|
215
|
+
throw new Error("Expected trace entry");
|
|
216
|
+
}
|
|
217
|
+
expect(traceEntry.error).toBeInstanceOf(Error);
|
|
218
|
+
if (traceEntry.error === undefined) {
|
|
219
|
+
throw new Error("Expected trace error");
|
|
220
|
+
}
|
|
221
|
+
expect(traceEntry.error.message).toBe("trace-boom");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("increments step index across calls (shared ref)", async () => {
|
|
225
|
+
const ctx = createMockCtx();
|
|
226
|
+
const $ = createStepBuilder({ ctx });
|
|
227
|
+
|
|
228
|
+
const r1 = await $.step({ id: "a", execute: async () => ({}) });
|
|
229
|
+
const r2 = await $.step({ id: "b", execute: async () => ({}) });
|
|
230
|
+
const r3 = await $.step({ id: "c", execute: async () => ({}) });
|
|
231
|
+
|
|
232
|
+
expect(r1.step.index).toBe(0);
|
|
233
|
+
expect(r2.step.index).toBe(1);
|
|
234
|
+
expect(r3.step.index).toBe(2);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("provides child $ for nested operations", async () => {
|
|
238
|
+
const ctx = createMockCtx();
|
|
239
|
+
const $$ = createStepBuilder({ ctx });
|
|
240
|
+
|
|
241
|
+
await $$.step({
|
|
242
|
+
id: "parent",
|
|
243
|
+
execute: async ({ $ }) => {
|
|
244
|
+
await $.step({ id: "child", execute: async () => ({}) });
|
|
245
|
+
return {};
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(ctx.trace).toHaveLength(1);
|
|
250
|
+
const traceEntry = ctx.trace[0];
|
|
251
|
+
if (traceEntry === undefined) {
|
|
252
|
+
throw new Error("Expected trace entry");
|
|
253
|
+
}
|
|
254
|
+
expect(traceEntry.children).toHaveLength(1);
|
|
255
|
+
if (traceEntry.children === undefined) {
|
|
256
|
+
throw new Error("Expected trace children");
|
|
257
|
+
}
|
|
258
|
+
const childEntry = traceEntry.children[0];
|
|
259
|
+
if (childEntry === undefined) {
|
|
260
|
+
throw new Error("Expected child trace entry");
|
|
261
|
+
}
|
|
262
|
+
expect(childEntry.id).toBe("child");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("handles primitive string return via value field", async () => {
|
|
266
|
+
const ctx = createMockCtx();
|
|
267
|
+
const $ = createStepBuilder({ ctx });
|
|
268
|
+
|
|
269
|
+
const result = await $.step({
|
|
270
|
+
id: "str",
|
|
271
|
+
execute: async () => "hello",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.ok).toBe(true);
|
|
275
|
+
if (!result.ok) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
expect(result.value).toBe("hello");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("handles primitive number return via value field", async () => {
|
|
282
|
+
const ctx = createMockCtx();
|
|
283
|
+
const $ = createStepBuilder({ ctx });
|
|
284
|
+
|
|
285
|
+
const result = await $.step({
|
|
286
|
+
id: "num",
|
|
287
|
+
execute: async () => 42,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(result.ok).toBe(true);
|
|
291
|
+
if (!result.ok) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
expect(result.value).toBe(42);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// agent() — delegates to step() with agent.generate() unwrap
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe("agent()", () => {
|
|
303
|
+
const MOCK_USAGE = {
|
|
304
|
+
inputTokens: 100,
|
|
305
|
+
outputTokens: 50,
|
|
306
|
+
totalTokens: 150,
|
|
307
|
+
cacheReadTokens: 0,
|
|
308
|
+
cacheWriteTokens: 0,
|
|
309
|
+
reasoningTokens: 0,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function mockAgent(result: Result<Pick<GenerateResult, "output" | "messages">>): Agent<string> {
|
|
313
|
+
const resolved: Result<GenerateResult> = match(result)
|
|
314
|
+
.with({ ok: true }, (r) => ({ ...r, usage: MOCK_USAGE, finishReason: "stop" as const }))
|
|
315
|
+
.otherwise((r) => r);
|
|
316
|
+
return {
|
|
317
|
+
generate: vi.fn(async () => resolved),
|
|
318
|
+
stream: vi.fn(),
|
|
319
|
+
fn: vi.fn(),
|
|
320
|
+
} as unknown as Agent<string>;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
it("unwraps successful agent result into StepResult", async () => {
|
|
324
|
+
const ctx = createMockCtx();
|
|
325
|
+
const $ = createStepBuilder({ ctx });
|
|
326
|
+
const agent = mockAgent({
|
|
327
|
+
ok: true,
|
|
328
|
+
output: "hello",
|
|
329
|
+
messages: [],
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const result = await $.agent({ id: "ag", agent, input: "test" });
|
|
333
|
+
|
|
334
|
+
expect(result.ok).toBe(true);
|
|
335
|
+
if (!result.ok) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
expect(result.value.output).toBe("hello");
|
|
339
|
+
expect(result.value.messages).toEqual([]);
|
|
340
|
+
expect(result.value.usage).toEqual(MOCK_USAGE);
|
|
341
|
+
expect(result.value.finishReason).toBe("stop");
|
|
342
|
+
expect(result.step.type).toBe("agent");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("converts agent error result into StepError", async () => {
|
|
346
|
+
const ctx = createMockCtx();
|
|
347
|
+
const $ = createStepBuilder({ ctx });
|
|
348
|
+
const agent = mockAgent({
|
|
349
|
+
ok: false,
|
|
350
|
+
error: { code: "AGENT_ERROR", message: "agent failed", cause: new Error("root") },
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const result = await $.agent({ id: "ag-err", agent, input: "test" });
|
|
354
|
+
|
|
355
|
+
expect(result.ok).toBe(false);
|
|
356
|
+
if (result.ok) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
expect(result.error.code).toBe("STEP_ERROR");
|
|
360
|
+
expect(result.error.stepId).toBe("ag-err");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("calls agent.generate with input and config", async () => {
|
|
364
|
+
const ctx = createMockCtx();
|
|
365
|
+
const $ = createStepBuilder({ ctx });
|
|
366
|
+
const agent = mockAgent({ ok: true, output: "hi", messages: [] });
|
|
367
|
+
const config = { signal: new AbortController().signal };
|
|
368
|
+
|
|
369
|
+
await $.agent({ id: "ag-cfg", agent, input: "hello", config });
|
|
370
|
+
|
|
371
|
+
expect(agent.generate).toHaveBeenCalledWith(
|
|
372
|
+
"hello",
|
|
373
|
+
expect.objectContaining({ signal: config.signal, logger: expect.any(Object) }),
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("propagates ctx.signal to agent when no user signal is provided", async () => {
|
|
378
|
+
const controller = new AbortController();
|
|
379
|
+
const ctx = createMockCtx({ signal: controller.signal });
|
|
380
|
+
const $ = createStepBuilder({ ctx });
|
|
381
|
+
const agent = mockAgent({ ok: true, output: "hi", messages: [] });
|
|
382
|
+
|
|
383
|
+
await $.agent({ id: "ag-ctx-signal", agent, input: "test" });
|
|
384
|
+
|
|
385
|
+
expect(agent.generate).toHaveBeenCalledWith(
|
|
386
|
+
"test",
|
|
387
|
+
expect.objectContaining({ signal: controller.signal }),
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("user-provided config.signal takes precedence over ctx.signal", async () => {
|
|
392
|
+
const ctxController = new AbortController();
|
|
393
|
+
const userController = new AbortController();
|
|
394
|
+
const ctx = createMockCtx({ signal: ctxController.signal });
|
|
395
|
+
const $ = createStepBuilder({ ctx });
|
|
396
|
+
const agent = mockAgent({ ok: true, output: "hi", messages: [] });
|
|
397
|
+
|
|
398
|
+
await $.agent({
|
|
399
|
+
id: "ag-user-signal",
|
|
400
|
+
agent,
|
|
401
|
+
input: "test",
|
|
402
|
+
config: { signal: userController.signal },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(agent.generate).toHaveBeenCalledWith(
|
|
406
|
+
"test",
|
|
407
|
+
expect.objectContaining({ signal: userController.signal }),
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("records input in trace", async () => {
|
|
412
|
+
const ctx = createMockCtx();
|
|
413
|
+
const $ = createStepBuilder({ ctx });
|
|
414
|
+
const agent = mockAgent({ ok: true, output: "hi", messages: [] });
|
|
415
|
+
|
|
416
|
+
await $.agent({ id: "ag-trace", agent, input: "my-input" });
|
|
417
|
+
|
|
418
|
+
const traceEntry = ctx.trace[0];
|
|
419
|
+
if (traceEntry === undefined) {
|
|
420
|
+
throw new Error("Expected trace entry");
|
|
421
|
+
}
|
|
422
|
+
expect(traceEntry.input).toBe("my-input");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("records usage on trace entry for successful agent step", async () => {
|
|
426
|
+
const ctx = createMockCtx();
|
|
427
|
+
const $ = createStepBuilder({ ctx });
|
|
428
|
+
const agent = mockAgent({ ok: true, output: "hi", messages: [] });
|
|
429
|
+
|
|
430
|
+
await $.agent({ id: "ag-usage-trace", agent, input: "test" });
|
|
431
|
+
|
|
432
|
+
const traceEntry = ctx.trace[0];
|
|
433
|
+
if (traceEntry === undefined) {
|
|
434
|
+
throw new Error("Expected trace entry");
|
|
435
|
+
}
|
|
436
|
+
expect(traceEntry.usage).toEqual(MOCK_USAGE);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("does not record usage on trace entry for failed agent step", async () => {
|
|
440
|
+
const ctx = createMockCtx();
|
|
441
|
+
const $ = createStepBuilder({ ctx });
|
|
442
|
+
const agent = mockAgent({
|
|
443
|
+
ok: false,
|
|
444
|
+
error: { code: "AGENT_ERROR", message: "failed" },
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await $.agent({ id: "ag-no-usage", agent, input: "test" });
|
|
448
|
+
|
|
449
|
+
const traceEntry = ctx.trace[0];
|
|
450
|
+
if (traceEntry === undefined) {
|
|
451
|
+
throw new Error("Expected trace entry");
|
|
452
|
+
}
|
|
453
|
+
expect(traceEntry.usage).toBeUndefined();
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// map() — delegates to step() with parallel execution
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
describe("map()", () => {
|
|
462
|
+
it("maps items in parallel via Promise.all by default", async () => {
|
|
463
|
+
const ctx = createMockCtx();
|
|
464
|
+
const $ = createStepBuilder({ ctx });
|
|
465
|
+
|
|
466
|
+
const result = await $.map({
|
|
467
|
+
id: "map-all",
|
|
468
|
+
input: [1, 2, 3],
|
|
469
|
+
execute: async ({ item }) => ({ doubled: item * 2 }),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(result.ok).toBe(true);
|
|
473
|
+
if (!result.ok) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// StepResult<R[]> spreads the array — it's on the result as the value
|
|
477
|
+
// Since T = { doubled: number }[], the spread puts the array properties on result
|
|
478
|
+
// Actually, for array types, `T & { ok: true, ... }` means array methods are available
|
|
479
|
+
expect(result.step.type).toBe("map");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("respects concurrency limit", async () => {
|
|
483
|
+
const ctx = createMockCtx();
|
|
484
|
+
const $ = createStepBuilder({ ctx });
|
|
485
|
+
const state = { maxConcurrent: 0, current: 0 };
|
|
486
|
+
|
|
487
|
+
await $.map({
|
|
488
|
+
id: "map-limited",
|
|
489
|
+
input: [1, 2, 3, 4, 5],
|
|
490
|
+
concurrency: 2,
|
|
491
|
+
execute: async ({ item }) => {
|
|
492
|
+
state.current++;
|
|
493
|
+
state.maxConcurrent = Math.max(state.maxConcurrent, state.current);
|
|
494
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
495
|
+
state.current--;
|
|
496
|
+
return { v: item };
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(state.maxConcurrent).toBeLessThanOrEqual(2);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("returns results in input order", async () => {
|
|
504
|
+
const ctx = createMockCtx();
|
|
505
|
+
const $ = createStepBuilder({ ctx });
|
|
506
|
+
|
|
507
|
+
const result = await $.map({
|
|
508
|
+
id: "map-order",
|
|
509
|
+
input: [3, 1, 2],
|
|
510
|
+
concurrency: 2,
|
|
511
|
+
execute: async ({ item }) => {
|
|
512
|
+
await new Promise((r) => setTimeout(r, item * 5));
|
|
513
|
+
return { v: item };
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
expect(result.ok).toBe(true);
|
|
518
|
+
if (!result.ok) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// The trace output should have items in original order
|
|
522
|
+
const traceEntry = ctx.trace[0];
|
|
523
|
+
if (traceEntry === undefined) {
|
|
524
|
+
throw new Error("Expected trace entry");
|
|
525
|
+
}
|
|
526
|
+
const output = traceEntry.output as Array<{ v: number }>;
|
|
527
|
+
expect(output.map((o) => o.v)).toEqual([3, 1, 2]);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// each() — delegates to step() with sequential iteration
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
describe("each()", () => {
|
|
536
|
+
it("iterates items sequentially", async () => {
|
|
537
|
+
const ctx = createMockCtx();
|
|
538
|
+
const $ = createStepBuilder({ ctx });
|
|
539
|
+
const order: number[] = [];
|
|
540
|
+
|
|
541
|
+
const result = await $.each({
|
|
542
|
+
id: "each-seq",
|
|
543
|
+
input: [1, 2, 3],
|
|
544
|
+
execute: async ({ item }) => {
|
|
545
|
+
order.push(item);
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(result.ok).toBe(true);
|
|
550
|
+
expect(order).toEqual([1, 2, 3]);
|
|
551
|
+
expect(result.step.type).toBe("each");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("propagates errors from execute", async () => {
|
|
555
|
+
const ctx = createMockCtx();
|
|
556
|
+
const $ = createStepBuilder({ ctx });
|
|
557
|
+
|
|
558
|
+
const result = await $.each({
|
|
559
|
+
id: "each-err",
|
|
560
|
+
input: [1, 2, 3],
|
|
561
|
+
execute: async ({ item }) => {
|
|
562
|
+
if (item === 2) {
|
|
563
|
+
throw new Error("stop at 2");
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
expect(result.ok).toBe(false);
|
|
569
|
+
if (result.ok) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
expect(result.error.message).toBe("stop at 2");
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// reduce() — delegates to step() with accumulator loop
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
describe("reduce()", () => {
|
|
581
|
+
it("accumulates values sequentially", async () => {
|
|
582
|
+
const ctx = createMockCtx();
|
|
583
|
+
const $ = createStepBuilder({ ctx });
|
|
584
|
+
|
|
585
|
+
const result = await $.reduce({
|
|
586
|
+
id: "reduce-sum",
|
|
587
|
+
input: [1, 2, 3, 4],
|
|
588
|
+
initial: 0,
|
|
589
|
+
execute: async ({ item, accumulator }) => accumulator + item,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(result.ok).toBe(true);
|
|
593
|
+
if (!result.ok) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
// The result of reduce is a number — spread onto the object
|
|
597
|
+
// For primitive types, the spread doesn't add properties,
|
|
598
|
+
// but the trace output captures it
|
|
599
|
+
const traceEntry = ctx.trace[0];
|
|
600
|
+
if (traceEntry === undefined) {
|
|
601
|
+
throw new Error("Expected trace entry");
|
|
602
|
+
}
|
|
603
|
+
expect(traceEntry.output).toBe(10);
|
|
604
|
+
expect(result.step.type).toBe("reduce");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("uses initial value when input is empty", async () => {
|
|
608
|
+
const ctx = createMockCtx();
|
|
609
|
+
const $ = createStepBuilder({ ctx });
|
|
610
|
+
|
|
611
|
+
const result = await $.reduce({
|
|
612
|
+
id: "reduce-empty",
|
|
613
|
+
input: [],
|
|
614
|
+
initial: 42,
|
|
615
|
+
execute: async ({ accumulator }) => accumulator,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(result.ok).toBe(true);
|
|
619
|
+
const traceEntry = ctx.trace[0];
|
|
620
|
+
if (traceEntry === undefined) {
|
|
621
|
+
throw new Error("Expected trace entry");
|
|
622
|
+
}
|
|
623
|
+
expect(traceEntry.output).toBe(42);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// while() — delegates to step() with conditional loop
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
describe("while()", () => {
|
|
632
|
+
it("loops while condition is true", async () => {
|
|
633
|
+
const ctx = createMockCtx();
|
|
634
|
+
const $ = createStepBuilder({ ctx });
|
|
635
|
+
|
|
636
|
+
const result = await $.while({
|
|
637
|
+
id: "while-count",
|
|
638
|
+
condition: ({ index }) => index < 3,
|
|
639
|
+
execute: async ({ index }) => ({ count: index }),
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
expect(result.ok).toBe(true);
|
|
643
|
+
expect(result.step.type).toBe("while");
|
|
644
|
+
const traceEntry = ctx.trace[0];
|
|
645
|
+
if (traceEntry === undefined) {
|
|
646
|
+
throw new Error("Expected trace entry");
|
|
647
|
+
}
|
|
648
|
+
const output = traceEntry.output as { count: number };
|
|
649
|
+
expect(output.count).toBe(2);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("returns undefined when condition is initially false", async () => {
|
|
653
|
+
const ctx = createMockCtx();
|
|
654
|
+
const $ = createStepBuilder({ ctx });
|
|
655
|
+
|
|
656
|
+
const result = await $.while({
|
|
657
|
+
id: "while-none",
|
|
658
|
+
condition: () => false,
|
|
659
|
+
execute: async () => ({ v: 1 }),
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(result.ok).toBe(true);
|
|
663
|
+
const traceEntry = ctx.trace[0];
|
|
664
|
+
if (traceEntry === undefined) {
|
|
665
|
+
throw new Error("Expected trace entry");
|
|
666
|
+
}
|
|
667
|
+
expect(traceEntry.output).toBeUndefined();
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
// all() — delegates to step() with Promise.all
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
describe("all()", () => {
|
|
676
|
+
it("resolves all entries concurrently", async () => {
|
|
677
|
+
const ctx = createMockCtx();
|
|
678
|
+
const $ = createStepBuilder({ ctx });
|
|
679
|
+
|
|
680
|
+
const result = await $.all({
|
|
681
|
+
id: "all-entries",
|
|
682
|
+
entries: [() => Promise.resolve("a"), () => Promise.resolve("b"), () => Promise.resolve("c")],
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
expect(result.ok).toBe(true);
|
|
686
|
+
if (!result.ok) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const traceEntry = ctx.trace[0];
|
|
690
|
+
if (traceEntry === undefined) {
|
|
691
|
+
throw new Error("Expected trace entry");
|
|
692
|
+
}
|
|
693
|
+
const output = traceEntry.output as string[];
|
|
694
|
+
expect(output).toEqual(["a", "b", "c"]);
|
|
695
|
+
expect(result.step.type).toBe("all");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("fails fast on first error", async () => {
|
|
699
|
+
const ctx = createMockCtx();
|
|
700
|
+
const $ = createStepBuilder({ ctx });
|
|
701
|
+
|
|
702
|
+
const result = await $.all({
|
|
703
|
+
id: "all-fail",
|
|
704
|
+
entries: [
|
|
705
|
+
() => Promise.resolve("a"),
|
|
706
|
+
() => Promise.reject(new Error("fail")),
|
|
707
|
+
() => Promise.resolve("c"),
|
|
708
|
+
],
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
expect(result.ok).toBe(false);
|
|
712
|
+
if (result.ok) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
expect(result.error.message).toBe("fail");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("passes abort signal to entry factories", async () => {
|
|
719
|
+
const ctx = createMockCtx();
|
|
720
|
+
const $ = createStepBuilder({ ctx });
|
|
721
|
+
const receivedSignals: AbortSignal[] = [];
|
|
722
|
+
|
|
723
|
+
await $.all({
|
|
724
|
+
id: "all-signal",
|
|
725
|
+
entries: [
|
|
726
|
+
(signal) => {
|
|
727
|
+
receivedSignals.push(signal);
|
|
728
|
+
return Promise.resolve("a");
|
|
729
|
+
},
|
|
730
|
+
(signal) => {
|
|
731
|
+
receivedSignals.push(signal);
|
|
732
|
+
return Promise.resolve("b");
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
expect(receivedSignals).toHaveLength(2);
|
|
738
|
+
expect(receivedSignals[0]).toBeInstanceOf(AbortSignal);
|
|
739
|
+
expect(receivedSignals[1]).toBeInstanceOf(AbortSignal);
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// race() — delegates to step() with Promise.race
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
describe("race()", () => {
|
|
748
|
+
it("returns first resolved value", async () => {
|
|
749
|
+
const ctx = createMockCtx();
|
|
750
|
+
const $ = createStepBuilder({ ctx });
|
|
751
|
+
|
|
752
|
+
const result = await $.race({
|
|
753
|
+
id: "race-first",
|
|
754
|
+
entries: [
|
|
755
|
+
() => new Promise((r) => setTimeout(() => r("slow"), 50)),
|
|
756
|
+
() => Promise.resolve("fast"),
|
|
757
|
+
],
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
expect(result.ok).toBe(true);
|
|
761
|
+
if (!result.ok) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const traceEntry = ctx.trace[0];
|
|
765
|
+
if (traceEntry === undefined) {
|
|
766
|
+
throw new Error("Expected trace entry");
|
|
767
|
+
}
|
|
768
|
+
expect(traceEntry.output).toBe("fast");
|
|
769
|
+
expect(result.step.type).toBe("race");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("cancels losing entries via abort signal", async () => {
|
|
773
|
+
const ctx = createMockCtx();
|
|
774
|
+
const $ = createStepBuilder({ ctx });
|
|
775
|
+
const signals: { loser: AbortSignal | undefined } = { loser: undefined };
|
|
776
|
+
|
|
777
|
+
const result = await $.race({
|
|
778
|
+
id: "race-cancel",
|
|
779
|
+
entries: [
|
|
780
|
+
() => Promise.resolve("winner"),
|
|
781
|
+
(signal) => {
|
|
782
|
+
signals.loser = signal;
|
|
783
|
+
return new Promise((r) => setTimeout(() => r("loser"), 500));
|
|
784
|
+
},
|
|
785
|
+
],
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
expect(result.ok).toBe(true);
|
|
789
|
+
if (!result.ok) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
expect(result.value).toBe("winner");
|
|
793
|
+
if (signals.loser === undefined) {
|
|
794
|
+
throw new Error("Expected loser signal");
|
|
795
|
+
}
|
|
796
|
+
expect(signals.loser.aborted).toBe(true);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// agent() streaming — exercises the stream path (lines 264-290)
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
describe("agent() streaming with writer", () => {
|
|
805
|
+
const MOCK_USAGE = {
|
|
806
|
+
inputTokens: 10,
|
|
807
|
+
outputTokens: 5,
|
|
808
|
+
totalTokens: 15,
|
|
809
|
+
cacheReadTokens: 0,
|
|
810
|
+
cacheWriteTokens: 0,
|
|
811
|
+
reasoningTokens: 0,
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Build a mock `WritableStreamDefaultWriter` that records writes
|
|
816
|
+
* without backpressure.
|
|
817
|
+
*/
|
|
818
|
+
function createMockWriter(): {
|
|
819
|
+
writer: WritableStreamDefaultWriter<StreamPart>;
|
|
820
|
+
written: StreamPart[];
|
|
821
|
+
} {
|
|
822
|
+
const written: StreamPart[] = [];
|
|
823
|
+
const writer = {
|
|
824
|
+
write: vi.fn(async (chunk: StreamPart) => {
|
|
825
|
+
written.push(chunk);
|
|
826
|
+
}),
|
|
827
|
+
close: vi.fn(async () => {}),
|
|
828
|
+
abort: vi.fn(async () => {}),
|
|
829
|
+
releaseLock: vi.fn(),
|
|
830
|
+
ready: Promise.resolve(undefined),
|
|
831
|
+
desiredSize: 1,
|
|
832
|
+
closed: new Promise<undefined>(() => {}),
|
|
833
|
+
} as unknown as WritableStreamDefaultWriter<StreamPart>;
|
|
834
|
+
return { writer, written };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Build a mock `AsyncIterableStream` from an array of parts.
|
|
839
|
+
*
|
|
840
|
+
* The factory code iterates via `for await (const part of full.fullStream)`,
|
|
841
|
+
* so we only need the `Symbol.asyncIterator` protocol.
|
|
842
|
+
*/
|
|
843
|
+
function createMockFullStream(parts: StreamPart[]) {
|
|
844
|
+
async function* gen() {
|
|
845
|
+
for (const part of parts) {
|
|
846
|
+
yield part;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// Return an object that satisfies AsyncIterable<StreamPart>
|
|
850
|
+
return {
|
|
851
|
+
[Symbol.asyncIterator]: gen,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
it("forwards text-delta events through the writer when stream=true", async () => {
|
|
856
|
+
const ctx = createMockCtx();
|
|
857
|
+
const { writer, written } = createMockWriter();
|
|
858
|
+
const $ = createStepBuilder({ ctx, writer });
|
|
859
|
+
|
|
860
|
+
const parts: StreamPart[] = [
|
|
861
|
+
{ type: "text-delta", textDelta: "hello " } as unknown as StreamPart,
|
|
862
|
+
{ type: "text-delta", textDelta: "world" } as unknown as StreamPart,
|
|
863
|
+
];
|
|
864
|
+
|
|
865
|
+
const mockStreamResult = {
|
|
866
|
+
ok: true as const,
|
|
867
|
+
output: Promise.resolve("hello world"),
|
|
868
|
+
messages: Promise.resolve([]),
|
|
869
|
+
usage: Promise.resolve(MOCK_USAGE),
|
|
870
|
+
finishReason: Promise.resolve("stop"),
|
|
871
|
+
fullStream: createMockFullStream(parts),
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
const agent = {
|
|
875
|
+
generate: vi.fn(),
|
|
876
|
+
stream: vi.fn(async () => mockStreamResult),
|
|
877
|
+
fn: vi.fn(),
|
|
878
|
+
} as unknown as Agent<string>;
|
|
879
|
+
|
|
880
|
+
const result = await $.agent({
|
|
881
|
+
id: "stream-agent",
|
|
882
|
+
agent,
|
|
883
|
+
input: "test",
|
|
884
|
+
stream: true,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
expect(result.ok).toBe(true);
|
|
888
|
+
if (!result.ok) return;
|
|
889
|
+
expect(result.step.type).toBe("agent");
|
|
890
|
+
expect(result.value.output).toBe("hello world");
|
|
891
|
+
expect(result.value.messages).toEqual([]);
|
|
892
|
+
expect(result.value.usage).toEqual(MOCK_USAGE);
|
|
893
|
+
expect(result.value.finishReason).toBe("stop");
|
|
894
|
+
expect(agent.stream).toHaveBeenCalled();
|
|
895
|
+
expect(agent.generate).not.toHaveBeenCalled();
|
|
896
|
+
|
|
897
|
+
// Verify text-delta events were forwarded to the writer
|
|
898
|
+
const textDeltas = written.filter((p) => p.type === "text-delta");
|
|
899
|
+
expect(textDeltas).toHaveLength(2);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("throws when streaming agent returns error result", async () => {
|
|
903
|
+
const ctx = createMockCtx();
|
|
904
|
+
const { writer } = createMockWriter();
|
|
905
|
+
const $ = createStepBuilder({ ctx, writer });
|
|
906
|
+
|
|
907
|
+
const agent = {
|
|
908
|
+
generate: vi.fn(),
|
|
909
|
+
stream: vi.fn(async () => ({
|
|
910
|
+
ok: false as const,
|
|
911
|
+
error: { code: "AGENT_ERROR", message: "stream failed", cause: new Error("root cause") },
|
|
912
|
+
})),
|
|
913
|
+
fn: vi.fn(),
|
|
914
|
+
} as unknown as Agent<string>;
|
|
915
|
+
|
|
916
|
+
const result = await $.agent({
|
|
917
|
+
id: "stream-err",
|
|
918
|
+
agent,
|
|
919
|
+
input: "test",
|
|
920
|
+
stream: true,
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
expect(result.ok).toBe(false);
|
|
924
|
+
if (result.ok) return;
|
|
925
|
+
expect(result.error.code).toBe("STEP_ERROR");
|
|
926
|
+
expect(result.error.stepId).toBe("stream-err");
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("throws with message when streaming agent error has no cause", async () => {
|
|
930
|
+
const ctx = createMockCtx();
|
|
931
|
+
const { writer } = createMockWriter();
|
|
932
|
+
const $ = createStepBuilder({ ctx, writer });
|
|
933
|
+
|
|
934
|
+
const agent = {
|
|
935
|
+
generate: vi.fn(),
|
|
936
|
+
stream: vi.fn(async () => ({
|
|
937
|
+
ok: false as const,
|
|
938
|
+
error: { code: "AGENT_ERROR", message: "no cause error" },
|
|
939
|
+
})),
|
|
940
|
+
fn: vi.fn(),
|
|
941
|
+
} as unknown as Agent<string>;
|
|
942
|
+
|
|
943
|
+
const result = await $.agent({
|
|
944
|
+
id: "stream-err-no-cause",
|
|
945
|
+
agent,
|
|
946
|
+
input: "test",
|
|
947
|
+
stream: true,
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
expect(result.ok).toBe(false);
|
|
951
|
+
if (result.ok) return;
|
|
952
|
+
expect(result.error.code).toBe("STEP_ERROR");
|
|
953
|
+
expect(result.error.message).toBe("no cause error");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("skips non-text-delta events from the stream", async () => {
|
|
957
|
+
const ctx = createMockCtx();
|
|
958
|
+
const { writer, written } = createMockWriter();
|
|
959
|
+
const $ = createStepBuilder({ ctx, writer });
|
|
960
|
+
|
|
961
|
+
const parts: StreamPart[] = [
|
|
962
|
+
{ type: "text-delta", textDelta: "hi" } as unknown as StreamPart,
|
|
963
|
+
{
|
|
964
|
+
type: "finish",
|
|
965
|
+
finishReason: "stop",
|
|
966
|
+
usage: { promptTokens: 1, completionTokens: 1 },
|
|
967
|
+
providerMetadata: undefined,
|
|
968
|
+
logprobs: undefined,
|
|
969
|
+
response: { id: "r", timestamp: new Date(), modelId: "m" },
|
|
970
|
+
} as unknown as StreamPart,
|
|
971
|
+
];
|
|
972
|
+
|
|
973
|
+
const mockStreamResult = {
|
|
974
|
+
ok: true as const,
|
|
975
|
+
output: Promise.resolve("hi"),
|
|
976
|
+
messages: Promise.resolve([]),
|
|
977
|
+
usage: Promise.resolve(MOCK_USAGE),
|
|
978
|
+
finishReason: Promise.resolve("stop"),
|
|
979
|
+
fullStream: createMockFullStream(parts),
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const agent = {
|
|
983
|
+
generate: vi.fn(),
|
|
984
|
+
stream: vi.fn(async () => mockStreamResult),
|
|
985
|
+
fn: vi.fn(),
|
|
986
|
+
} as unknown as Agent<string>;
|
|
987
|
+
|
|
988
|
+
await $.agent({
|
|
989
|
+
id: "stream-filter",
|
|
990
|
+
agent,
|
|
991
|
+
input: "test",
|
|
992
|
+
stream: true,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// Only one text-delta should have been forwarded via writer.write
|
|
996
|
+
// (the "finish" event should not be forwarded)
|
|
997
|
+
const textDeltas = written.filter((p) => p.type === "text-delta");
|
|
998
|
+
expect(textDeltas).toHaveLength(1);
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
// map() with aborted signal — exercises poolMap signal.aborted (line 613)
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
describe("map() with aborted signal", () => {
|
|
1007
|
+
it("throws immediately when signal is already aborted", async () => {
|
|
1008
|
+
const controller = new AbortController();
|
|
1009
|
+
controller.abort();
|
|
1010
|
+
const ctx = createMockCtx({ signal: controller.signal });
|
|
1011
|
+
const $ = createStepBuilder({ ctx });
|
|
1012
|
+
|
|
1013
|
+
const result = await $.map({
|
|
1014
|
+
id: "map-aborted",
|
|
1015
|
+
input: [1, 2, 3],
|
|
1016
|
+
concurrency: 2,
|
|
1017
|
+
execute: async ({ item }) => item * 2,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
expect(result.ok).toBe(false);
|
|
1021
|
+
if (result.ok) return;
|
|
1022
|
+
expect(result.error.code).toBe("STEP_ERROR");
|
|
1023
|
+
expect(result.error.message).toBe("Aborted");
|
|
1024
|
+
});
|
|
1025
|
+
});
|