@zhushanwen/pi-subagent-workflow 0.4.1 → 0.4.3

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.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * buildWorkerScript — 运行时执行回归测试。
3
+ *
4
+ * 现有的 worker-script-builder.test.ts 全是字符串 toContain 断言,无法捕获生成的
5
+ * worker 源码在「真实 Worker 线程里执行」时的运行时错误。曾因此漏掉 _safePost 作用域
6
+ * bug(定义在 async IIFE 内、却在 IIFE 外的 .then()/.catch() 里使用):脚本每次正常
7
+ * return 都触发 ReferenceError → Worker exit code 1 → 所有 workflow 100% 失败。
8
+ *
9
+ * 本测试起真实的 node:worker_threads.Worker 执行 buildWorkerScript 产物,覆盖:
10
+ * - 脚本正常 return → {type:"return"} 消息(非 exit code 1 崩溃)
11
+ * - 脚本 throw → {type:"error"} 消息 + workerLogs(诊断不丢)
12
+ * - agent() 调用链路:postMessage(agent-call) ↔ postMessage(agent-result)
13
+ * - abort 消息:pending agent() reject → WorkflowAbortedError
14
+ * - workflow() 嵌套调用链路
15
+ * - module.exports.execute() 自动调用入口
16
+ * - _safePost 的 DataCloneError 防御分支
17
+ *
18
+ * 这是回归防线:任何让 .then/.catch 访问不到 module-scope helper 的重构都会被这里抓住。
19
+ */
20
+ import { Worker } from "node:worker_threads";
21
+
22
+ import { afterEach, describe, expect, it } from "vitest";
23
+
24
+ import { buildWorkerScript } from "../worker-script-builder.ts";
25
+
26
+ // ── 判别联合:Worker → Main 消息类型(S3:用判别联合替代可选字段 + 非空断言)──
27
+
28
+ /** agent-call 消息:worker 请求主线程执行一个 agent。 */
29
+ interface AgentCallMsg {
30
+ type: "agent-call";
31
+ callId: number;
32
+ opts: { prompt: string; description?: string; schema?: unknown; [k: string]: unknown };
33
+ phase?: string;
34
+ }
35
+ /** workflow-call 消息:worker 请求主线程执行嵌套 workflow。 */
36
+ interface WorkflowCallMsg {
37
+ type: "workflow-call";
38
+ callId: number;
39
+ name: string;
40
+ args: Record<string, unknown>;
41
+ }
42
+ /** return 消息:脚本正常结束,带回结果。 */
43
+ interface ReturnMsg {
44
+ type: "return";
45
+ runId?: string;
46
+ result: unknown;
47
+ workerLogs?: unknown[];
48
+ }
49
+ /** error 消息:脚本抛错(含 _safePost 的 DataCloneError 防御路径)。 */
50
+ interface ErrorMsg {
51
+ type: "error";
52
+ runId?: string;
53
+ error: string;
54
+ workerLogs?: unknown[];
55
+ }
56
+
57
+ // ── 类型守卫:从 unknown 收窄到判别联合 ──
58
+ // 共享 hasType 辅助:避免每个守卫重复 `(m as {type?:string})` 断言(taste/no-unsafe-catch)。
59
+
60
+ function hasType<T extends string>(m: unknown, type: T): boolean {
61
+ return typeof m === "object" && m !== null
62
+ && (m as { type: unknown }).type === type;
63
+ }
64
+
65
+ function isAgentCall(m: unknown): m is AgentCallMsg {
66
+ return hasType(m, "agent-call");
67
+ }
68
+ function isWorkflowCall(m: unknown): m is WorkflowCallMsg {
69
+ return hasType(m, "workflow-call");
70
+ }
71
+ function isReturn(m: unknown): m is ReturnMsg {
72
+ return hasType(m, "return");
73
+ }
74
+ function isError(m: unknown): m is ErrorMsg {
75
+ return hasType(m, "error");
76
+ }
77
+
78
+ // ── 测试辅助:起一个真实 Worker 跑 buildWorkerScript 产物 ──────────────
79
+
80
+ interface RunResult {
81
+ /** 收到的 return 消息的 result 字段(脚本正常结束时)。 */
82
+ returnValue?: unknown;
83
+ /** 收到的 error 消息的 error 字段(脚本 throw 时)。 */
84
+ errorMessage?: string;
85
+ /** error 消息带回的 workerLogs(验证诊断不丢)。 */
86
+ errorWorkerLogs?: unknown[];
87
+ /** Worker exit code(0=正常,1=崩溃)。 */
88
+ exitCode?: number;
89
+ /** Worker 'error' 事件的错误消息(uncaught exception,正常应为 undefined)。 */
90
+ workerError?: string;
91
+ /** 收到的 agent-call 消息列表。 */
92
+ agentCalls: AgentCallMsg[];
93
+ /** 收到的 workflow-call 消息列表。 */
94
+ workflowCalls: WorkflowCallMsg[];
95
+ }
96
+
97
+ interface RunOptions {
98
+ /** $ARGS。 */
99
+ args?: Record<string, unknown>;
100
+ /** 按 agent-call 顺序回发的 parsedOutput(默认每个回发 {ok:true})。 */
101
+ agentResults?: unknown[];
102
+ /** 主线程对收到的 workflow-call 的处理:回发 workflow-result。 */
103
+ handleWorkflowCall?: (msg: WorkflowCallMsg) => unknown;
104
+ /** 是否在收到首个 agent-call 后立即发 abort(测 abort 路径)。 */
105
+ abortAfterFirstAgentCall?: { reason: string };
106
+ /** 超时(S9:CI 环境放宽,规避真实 Worker 启动慢导致的假阳)。 */
107
+ timeoutMs?: number;
108
+ /** workerData.callCache 预填(测缓存命中路径)。 */
109
+ callCache?: Map<number, unknown>;
110
+ }
111
+
112
+ /**
113
+ * 起 Worker 执行 userScript,主线程模拟 workflow runtime 回发 agent-result。
114
+ *
115
+ * @param userScript 用户 workflow 脚本源码
116
+ */
117
+ function runWorker(userScript: string, opts: RunOptions = {}): Promise<RunResult> {
118
+ const timeoutMs = opts.timeoutMs ?? (process.env.CI ? 5000 : 2000);
119
+ return new Promise((resolve, reject) => {
120
+ const workerCode = buildWorkerScript(userScript);
121
+ const worker = new Worker(workerCode, {
122
+ eval: true,
123
+ workerData: {
124
+ scriptPath: "test.js",
125
+ args: opts.args ?? {},
126
+ workspace: process.cwd(),
127
+ budget: { maxTokens: 0, usedTokens: 0, usedCost: 0 },
128
+ callCache: opts.callCache instanceof Map
129
+ ? Object.fromEntries(opts.callCache)
130
+ : opts.callCache ?? {},
131
+ },
132
+ });
133
+ // S8:创建后立即登记,afterEach 兜底清理(防止 promise 泄漏导致 Worker 未终止)
134
+ createdWorkers.push(worker);
135
+
136
+ const result: RunResult = { agentCalls: [], workflowCalls: [] };
137
+ let agentCallIdx = 0;
138
+ let resolved = false;
139
+ const timer = setTimeout(() => {
140
+ worker.terminate().catch(() => {});
141
+ reject(new Error(`Worker timed out after ${timeoutMs}ms — likely hung`));
142
+ }, timeoutMs);
143
+
144
+ const finish = (r: RunResult): void => {
145
+ if (resolved) return;
146
+ resolved = true;
147
+ clearTimeout(timer);
148
+ resolve(r);
149
+ };
150
+
151
+ worker.on("message", (raw: unknown) => {
152
+ if (isAgentCall(raw)) {
153
+ result.agentCalls.push(raw);
154
+ if (opts.abortAfterFirstAgentCall) {
155
+ worker.postMessage({ type: "abort", reason: opts.abortAfterFirstAgentCall.reason });
156
+ return;
157
+ }
158
+ const parsed = opts.agentResults?.[agentCallIdx] ?? { ok: true };
159
+ agentCallIdx++;
160
+ worker.postMessage({
161
+ type: "agent-result",
162
+ callId: raw.callId,
163
+ result: { content: "fallback", parsedOutput: parsed },
164
+ cached: false,
165
+ });
166
+ } else if (isWorkflowCall(raw)) {
167
+ result.workflowCalls.push(raw);
168
+ const wfResult = opts.handleWorkflowCall ? opts.handleWorkflowCall(raw) : { ok: true };
169
+ worker.postMessage({ type: "workflow-result", callId: raw.callId, result: wfResult });
170
+ } else if (isReturn(raw)) {
171
+ result.returnValue = raw.result;
172
+ finish(result);
173
+ } else if (isError(raw)) {
174
+ result.errorMessage = raw.error;
175
+ result.errorWorkerLogs = raw.workerLogs;
176
+ finish(result);
177
+ }
178
+ });
179
+ worker.on("error", (err: Error) => {
180
+ result.workerError = err.message;
181
+ // error 事件后 Worker 会 exit code 1,给 exit handler 一个 tick 记录 exitCode
182
+ });
183
+ worker.on("exit", (code: number) => {
184
+ result.exitCode = code;
185
+ // 若未通过 return/error 消息结束(即 Worker 崩溃),以 exit 结果收尾
186
+ if (result.returnValue === undefined && result.errorMessage === undefined) {
187
+ finish(result);
188
+ }
189
+ });
190
+ });
191
+ }
192
+
193
+ // 记录所有创建的 Worker,afterEach 兜底清理(防止泄漏)——S8
194
+ const createdWorkers: Worker[] = [];
195
+
196
+ afterEach(() => {
197
+ for (const w of createdWorkers.splice(0)) {
198
+ w.terminate().catch(() => {});
199
+ }
200
+ });
201
+
202
+ // ── 回归测试:_safePost 作用域 bug(核心防线) ──────────────────────
203
+
204
+ describe("buildWorkerScript runtime — _safePost scope regression (exit code 1 bug)", () => {
205
+ it("脚本正常 return 时发出 return 消息,Worker 不崩溃(exit code 0)", async () => {
206
+ const script = `return { status: "ok", value: 42 };`;
207
+ const res = await runWorker(script);
208
+ expect(res.workerError).toBeUndefined();
209
+ expect(res.errorMessage).toBeUndefined();
210
+ expect(res.returnValue).toEqual({ status: "ok", value: 42 });
211
+ expect(res.exitCode).not.toBe(1);
212
+ });
213
+
214
+ it("脚本 throw 时发出 error 消息并带回 workerLogs,Worker 不裸崩", async () => {
215
+ const script = `
216
+ console.log("before throw");
217
+ throw new Error("script boom");
218
+ `;
219
+ const res = await runWorker(script);
220
+ expect(res.workerError).toBeUndefined();
221
+ expect(res.errorMessage).toBe("script boom");
222
+ expect(res.exitCode).not.toBe(1);
223
+ });
224
+
225
+ it("agent() → result → return 完整链路:parallel 风格脚本正常完成", async () => {
226
+ const script = `
227
+ phase("analyze");
228
+ const results = await parallel([
229
+ () => agent({ prompt: "task-1", description: "a1" }),
230
+ () => agent({ prompt: "task-2", description: "a2" }),
231
+ ]);
232
+ const ok = results.filter((r) => r && r.ok).length;
233
+ return { status: "ok", analyzed: results.length, ok };
234
+ `;
235
+ const res = await runWorker(script, { agentResults: [{ ok: true }, { ok: true }] });
236
+ expect(res.agentCalls).toHaveLength(2);
237
+ expect(res.workerError).toBeUndefined();
238
+ expect(res.returnValue).toEqual({ status: "ok", analyzed: 2, ok: 2 });
239
+ expect(res.exitCode).not.toBe(1);
240
+ });
241
+
242
+ it("脚本 return 后 Worker 不发 workerError 事件(_safePost 在 .then 可达)", async () => {
243
+ const script = `return "done";`;
244
+ const res = await runWorker(script);
245
+ expect(res.workerError).toBeUndefined();
246
+ expect(res.returnValue).toBe("done");
247
+ });
248
+
249
+ it("parallel([agent(...), ...]) Promise 数组:CC 兼容写法不触发 DataCloneError", async () => {
250
+ // parallel.js/map-reduce.js/scatter-gather.js 都用 `parallel([agent({...}), ...])`——
251
+ // 传入已实例化的 Promise 数组(agent() 同步返回 Promise)。旧 parallel() 实现把
252
+ // Promise 当 opts 传给 agent() → postMessage DataCloneError → allSettled 全 rejected
253
+ // → 脚本返回 error。修复:parallel() 用 thenable 鸭辨直接返回 in-flight Promise。
254
+ // 此测试用真实的 Promise 数组写法(而非函数数组),对应内置脚本的真实用法。
255
+ const script = `
256
+ const results = await parallel([
257
+ agent({ prompt: "p1", description: "a1" }),
258
+ agent({ prompt: "p2", description: "a2" }),
259
+ ]);
260
+ return { count: results.length, ok: results.every((r) => r && r.ok) };
261
+ `;
262
+ const res = await runWorker(script, { agentResults: [{ ok: true }, { ok: true }] });
263
+ expect(res.agentCalls).toHaveLength(2);
264
+ expect(res.workerError).toBeUndefined();
265
+ expect(res.errorMessage).toBeUndefined();
266
+ expect(res.returnValue).toEqual({ count: 2, ok: true });
267
+ expect(res.exitCode).not.toBe(1);
268
+ });
269
+ });
270
+
271
+ // ── S4-S7:覆盖此前缺失的运行时路径 ──────────────────────────────────
272
+
273
+ describe("buildWorkerScript runtime — 之前缺失的路径覆盖", () => {
274
+ it("S4 abort 消息:pending agent() 被 reject → WorkflowAbortedError", async () => {
275
+ // 脚本 await 一个 agent(),主线程回发 abort → agent reject → 脚本抛错进 .catch
276
+ const script = `
277
+ await agent({ prompt: "will-be-aborted" });
278
+ `;
279
+ const res = await runWorker(script, { abortAfterFirstAgentCall: { reason: "user cancel" } });
280
+ // abort 让 pending reject → 脚本 throw WorkflowAbortedError → .catch 发 type:error
281
+ expect(res.agentCalls).toHaveLength(1);
282
+ expect(res.workerError).toBeUndefined();
283
+ expect(res.errorMessage).toMatch(/Workflow aborted/);
284
+ expect(res.exitCode).not.toBe(1);
285
+ });
286
+
287
+ it("S5 workflow() 嵌套调用:workflow-call ↔ workflow-result 链路正常", async () => {
288
+ const script = `
289
+ const r = await workflow("sub-wf", { x: 1 });
290
+ return { nested: r };
291
+ `;
292
+ const res = await runWorker(script, {
293
+ handleWorkflowCall: (msg) => ({ echo: msg.args, name: msg.name }),
294
+ });
295
+ expect(res.workflowCalls).toHaveLength(1);
296
+ expect(res.workflowCalls[0]!.name).toBe("sub-wf");
297
+ expect(res.workerError).toBeUndefined();
298
+ expect(res.returnValue).toEqual({ nested: { echo: { x: 1 }, name: "sub-wf" } });
299
+ expect(res.exitCode).not.toBe(1);
300
+ });
301
+
302
+ it("S6 module.exports.execute() 自动调用入口:ctx 注入完整、return 正常", async () => {
303
+ const script = `
304
+ const meta = { name: "exec-mode" };
305
+ module.exports = {
306
+ meta,
307
+ execute: async (ctx) => {
308
+ const r = await ctx.agent({ prompt: "via-execute" });
309
+ return { viaExecute: true, agentResult: r, hasGlobals: typeof ctx.parallel === "function" };
310
+ },
311
+ };
312
+ `;
313
+ const res = await runWorker(script, { agentResults: [{ ok: true, source: "exec" }] });
314
+ expect(res.agentCalls).toHaveLength(1);
315
+ expect(res.workerError).toBeUndefined();
316
+ expect(res.returnValue).toEqual({
317
+ viaExecute: true,
318
+ agentResult: { ok: true, source: "exec" },
319
+ hasGlobals: true,
320
+ });
321
+ expect(res.exitCode).not.toBe(1);
322
+ });
323
+
324
+ it("S7 _safePost 的 .catch 路径带回 workerLogs:脚本 throw 时诊断不丢", async () => {
325
+ // _safePost 的价值两半:(1) return 路径成功发消息(S1-S3 覆盖);
326
+ // (2) error 路径(.catch)发 type:error + workerLogs,让主线程拿到诊断。
327
+ // 本例验证 .catch 里的 _safePost 正常工作——脚本 throw → console.* 被劫持进
328
+ // _workerLogs → .catch 用 _safePost 发回 {type:"error", workerLogs}。
329
+ // 修复前 .catch 里的 _safePost 是 ReferenceError,workerLogs 发不回(errorLogs 全空)。
330
+ const script = `
331
+ console.log("step-1");
332
+ console.warn("step-2-warning");
333
+ throw new Error("diagnostic-test-error");
334
+ `;
335
+ const res = await runWorker(script);
336
+ expect(res.workerError).toBeUndefined();
337
+ expect(res.errorMessage).toBe("diagnostic-test-error");
338
+ expect(res.errorWorkerLogs).toBeDefined();
339
+ expect(res.errorWorkerLogs).toEqual(
340
+ expect.arrayContaining([
341
+ expect.objectContaining({ level: "log", message: "step-1" }),
342
+ expect.objectContaining({ level: "warn", message: "step-2-warning" }),
343
+ ]),
344
+ );
345
+ expect(res.exitCode).not.toBe(1);
346
+ });
347
+ });
@@ -55,3 +55,79 @@ describe("buildWorkerScript — agent() skill field in task/agent branch", () =>
55
55
  expect(taskAgentBranch![0]).toContain("skill: firstArg.skill");
56
56
  });
57
57
  });
58
+
59
+ // ── W1: postMessage 防御 + parallel() 降级类型安全 ──
60
+
61
+ describe("buildWorkerScript — W1 postMessage defense & parallel degrade", () => {
62
+ const script = buildWorkerScript("// noop user script");
63
+
64
+ describe("_safePost wrapper", () => {
65
+ it("injects _safePost function", () => {
66
+ expect(script).toContain("function _safePost(msg, context)");
67
+ });
68
+
69
+ it("_safePost wraps postMessage in try/catch", () => {
70
+ // _safePost 在 module scope(parentPort 解析为 _parentPort),
71
+ // 用宽松正则匹配「try { <something>.postMessage(msg)」避免绑死变量名。
72
+ expect(script).toMatch(/_safePost[\s\S]*?try \{ _parentPort\.postMessage\(msg\)/);
73
+ });
74
+
75
+ it("_safePost logs failure with context to workerLogs", () => {
76
+ expect(script).toContain('_pushWorkerLog("error"');
77
+ expect(script).toContain('"[postMessage failed:" + context + "]"');
78
+ });
79
+ });
80
+
81
+ describe("agent() uses _safePost", () => {
82
+ it("agent-call postMessage guarded by _safePost", () => {
83
+ expect(script).toContain("_safePost({ type: \"agent-call\"");
84
+ expect(script).toContain('"agent-call"');
85
+ });
86
+
87
+ it("agent() throws on postMessage failure", () => {
88
+ expect(script).toContain("postMessage failed for agent-call");
89
+ });
90
+ });
91
+
92
+ describe("workflow() uses _safePost", () => {
93
+ it("workflow-call postMessage guarded by _safePost", () => {
94
+ expect(script).toContain("_safePost({ type: \"workflow-call\"");
95
+ });
96
+ });
97
+
98
+ describe("return/error use _safePost", () => {
99
+ it("return postMessage uses _safePost", () => {
100
+ expect(script).toContain('_safePost({ type: "return"');
101
+ });
102
+
103
+ it("error postMessage uses _safePost", () => {
104
+ expect(script).toContain('_safePost({ type: "error"');
105
+ });
106
+ });
107
+
108
+ describe("parallel() degrade returns object", () => {
109
+ it("rejected results become {status:failed,error} objects", () => {
110
+ expect(script).toContain('status: "failed"');
111
+ expect(script).toMatch(/parallel[\s\S]*?status: "failed"/);
112
+ });
113
+
114
+ it("non-object fulfilled values wrapped as failed", () => {
115
+ expect(script).toContain("agent returned non-object result");
116
+ });
117
+
118
+ it("object fulfilled values pass through unchanged", () => {
119
+ expect(script).toContain("!Array.isArray(v)");
120
+ });
121
+ });
122
+
123
+ describe("pipeline() error observability", () => {
124
+ it("single-arg mode logs stage errors before re-throwing", () => {
125
+ expect(script).toContain("[pipeline stage ");
126
+ expect(script).toContain("_pushWorkerLog(\"error\"");
127
+ });
128
+
129
+ it("cartesian mode logs stage errors instead of silent swallow", () => {
130
+ expect(script).toContain("[pipeline cartesian stage failed");
131
+ });
132
+ });
133
+ });