@zhushanwen/pi-subagent-workflow 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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,359 @@
1
+ // src/__tests__/session-reconstructor.test.ts
2
+ //
3
+ // session-reconstructor 专属测试。
4
+ // 覆盖:从 session.jsonl 重建 turns[]/usage/result/error/eventLog;
5
+ // identity custom entry 解析;toolCall↔toolResult 配对;
6
+ // 防御性降级(文件缺失/损坏/缺 identity/无 assistant message)。
7
+ //
8
+ // 用 tmpdir + 真实 .jsonl 文件(隔离文件系统)。
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+
16
+ import { IDENTITY_CUSTOM_TYPE, reconstructFromFile } from "../session-reconstructor.ts";
17
+
18
+ /** 写一行到文件(JSON.stringify + 换行)。 */
19
+ function writeLine(file: number | fs.PathOrFileDescriptor, obj: unknown): void {
20
+ fs.writeSync(file, `${JSON.stringify(obj)}\n`);
21
+ }
22
+
23
+ /** session header 行。 */
24
+ function headerLine(cwd = "/tmp"): unknown {
25
+ return { type: "session", version: 3, id: "sess-uuid", timestamp: "2026-01-01T00:00:00.000Z", cwd };
26
+ }
27
+
28
+ /** identity custom entry。 */
29
+ function identityEntry(identity: object): unknown {
30
+ return {
31
+ type: "custom", id: "id-1", parentId: null, timestamp: "2026-01-01T00:00:00.000Z",
32
+ customType: IDENTITY_CUSTOM_TYPE, data: identity,
33
+ };
34
+ }
35
+
36
+ /** assistant message entry(content blocks + usage + stopReason)。 */
37
+ function assistantEntry(
38
+ blocks: object[],
39
+ opts: { usage?: object; stopReason?: string; errorMessage?: string; ts?: number; parentId?: string } = {},
40
+ ): unknown {
41
+ return {
42
+ type: "message", id: `msg-${Math.random().toString(36).slice(2, 8)}`,
43
+ parentId: opts.parentId ?? "id-1",
44
+ timestamp: new Date(opts.ts ?? 1000).toISOString(),
45
+ message: {
46
+ role: "assistant",
47
+ content: blocks,
48
+ usage: opts.usage ?? { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30, cost: { total: 0 } },
49
+ stopReason: opts.stopReason ?? "stop",
50
+ errorMessage: opts.errorMessage,
51
+ timestamp: opts.ts ?? 1000,
52
+ },
53
+ };
54
+ }
55
+
56
+ /** toolResult message entry。 */
57
+ function toolResultEntry(toolCallId: string, toolName: string, opts: { isError?: boolean; parentId?: string; text?: string } = {}): unknown {
58
+ return {
59
+ type: "message", id: `tr-${Math.random().toString(36).slice(2, 8)}`,
60
+ parentId: opts.parentId ?? "id-1",
61
+ timestamp: new Date(2000).toISOString(),
62
+ message: {
63
+ role: "toolResult", toolCallId, toolName,
64
+ content: [{ type: "text", text: opts.text ?? "result" }],
65
+ isError: opts.isError ?? false,
66
+ timestamp: 2000,
67
+ },
68
+ };
69
+ }
70
+
71
+ describe("reconstructFromFile", () => {
72
+ let tmpDir: string;
73
+ let filePath: string;
74
+
75
+ beforeEach(() => {
76
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sr-test-"));
77
+ filePath = path.join(tmpDir, "test.jsonl");
78
+ });
79
+ afterEach(() => {
80
+ fs.rmSync(tmpDir, { recursive: true, force: true });
81
+ });
82
+
83
+ function writeJsonl(lines: unknown[]): void {
84
+ const fd = fs.openSync(filePath, "w");
85
+ for (const line of lines) writeLine(fd, line);
86
+ fs.closeSync(fd);
87
+ }
88
+
89
+ // ============================================================
90
+ // 基本重建
91
+ // ============================================================
92
+ describe("基本重建", () => {
93
+ it("单 assistant message → 1 turn,text/usage 正确", () => {
94
+ writeJsonl([
95
+ headerLine(),
96
+ identityEntry({ id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 500 }),
97
+ assistantEntry([{ type: "text", text: "hello world" }]),
98
+ ]);
99
+ const rec = reconstructFromFile(filePath);
100
+ expect(rec).toBeDefined();
101
+ expect(rec!.id).toBe("bg-1");
102
+ expect(rec!.agent).toBe("worker");
103
+ expect(rec!.mode).toBe("background");
104
+ expect(rec!.task).toBe("do it");
105
+ expect(rec!.status).toBe("done");
106
+ expect(rec!.turns).toHaveLength(1);
107
+ expect(rec!.turns[0].text).toBe("hello world");
108
+ expect(rec!.turnCount).toBe(1);
109
+ expect(rec!.totalTokens).toBe(30); // 10+20+0+0
110
+ expect(rec!.result).toBe("hello world");
111
+ });
112
+
113
+ it("thinking block 累积进 turn.thinking", () => {
114
+ writeJsonl([
115
+ headerLine(),
116
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
117
+ assistantEntry([
118
+ { type: "thinking", thinking: "let me think" },
119
+ { type: "text", text: "answer" },
120
+ ]),
121
+ ]);
122
+ const rec = reconstructFromFile(filePath);
123
+ expect(rec!.turns[0].thinking).toBe("let me think");
124
+ expect(rec!.turns[0].text).toBe("answer");
125
+ });
126
+
127
+ it("多 assistant message → 多 turn,result 用空行拼接", () => {
128
+ writeJsonl([
129
+ headerLine(),
130
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
131
+ assistantEntry([{ type: "text", text: "first" }], { ts: 1000 }),
132
+ assistantEntry([{ type: "text", text: "second" }], { ts: 2000, parentId: undefined }),
133
+ ]);
134
+ const rec = reconstructFromFile(filePath);
135
+ expect(rec!.turns).toHaveLength(2);
136
+ expect(rec!.turnCount).toBe(2);
137
+ expect(rec!.result).toBe("first\n\nsecond");
138
+ });
139
+
140
+ it("读出 identity 里的 rootSessionId", () => {
141
+ writeJsonl([
142
+ headerLine(),
143
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 100, rootSessionId: "sess-A" }),
144
+ assistantEntry([{ type: "text", text: "ok" }]),
145
+ ]);
146
+ const rec = reconstructFromFile(filePath);
147
+ expect(rec!.rootSessionId).toBe("sess-A");
148
+ });
149
+
150
+ it("旧文件 identity 写 parentSessionId → fallback 读到 rootSessionId(向后兼容)", () => {
151
+ writeJsonl([
152
+ headerLine(),
153
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 100, parentSessionId: "sess-legacy" }),
154
+ assistantEntry([{ type: "text", text: "ok" }]),
155
+ ]);
156
+ const rec = reconstructFromFile(filePath);
157
+ expect(rec!.rootSessionId).toBe("sess-legacy");
158
+ });
159
+
160
+ it("identity 无 rootSessionId(旧文件)→ rootSessionId 为 undefined", () => {
161
+ writeJsonl([
162
+ headerLine(),
163
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 100 }),
164
+ assistantEntry([{ type: "text", text: "ok" }]),
165
+ ]);
166
+ const rec = reconstructFromFile(filePath);
167
+ expect(rec!.rootSessionId).toBeUndefined();
168
+ });
169
+
170
+ it("读出 identity 里的 parentRecordId/depth(递归层级)", () => {
171
+ writeJsonl([
172
+ headerLine(),
173
+ identityEntry({ id: "run-2", agent: "w", mode: "sync", task: "t", startedAt: 100, rootSessionId: "sess-A", parentRecordId: "run-1", depth: 2 }),
174
+ assistantEntry([{ type: "text", text: "ok" }]),
175
+ ]);
176
+ const rec = reconstructFromFile(filePath);
177
+ expect(rec!.parentRecordId).toBe("run-1");
178
+ expect(rec!.depth).toBe(2);
179
+ });
180
+
181
+ it("旧文件无 parentRecordId/depth → 兑底 undefined/0(顶层)", () => {
182
+ writeJsonl([
183
+ headerLine(),
184
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 100, rootSessionId: "sess-A" }),
185
+ assistantEntry([{ type: "text", text: "ok" }]),
186
+ ]);
187
+ const rec = reconstructFromFile(filePath);
188
+ expect(rec!.parentRecordId).toBeUndefined();
189
+ expect(rec!.depth).toBe(0);
190
+ });
191
+
192
+ it("endedAt 为最后一条 entry 的时间戳(非 now)", () => {
193
+ writeJsonl([
194
+ headerLine(),
195
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 100 }),
196
+ assistantEntry([{ type: "text", text: "first" }], { ts: 1000 }),
197
+ assistantEntry([{ type: "text", text: "second" }], { ts: 5000, parentId: undefined }),
198
+ ]);
199
+ const rec = reconstructFromFile(filePath);
200
+ expect(rec!.endedAt).toBe(5000);
201
+ });
202
+ });
203
+
204
+ // ============================================================
205
+ // toolCall ↔ toolResult 配对
206
+ // ============================================================
207
+ describe("toolCall 配对", () => {
208
+ it("toolCall + toolResult → InternalToolCall done", () => {
209
+ writeJsonl([
210
+ headerLine(),
211
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
212
+ assistantEntry([
213
+ { type: "toolCall", id: "call-1", name: "read", arguments: { path: "/x.ts" } },
214
+ ]),
215
+ toolResultEntry("call-1", "read"),
216
+ ]);
217
+ const rec = reconstructFromFile(filePath);
218
+ expect(rec!.turns[0].toolCalls).toHaveLength(1);
219
+ const tc = rec!.turns[0].toolCalls[0];
220
+ expect(tc.toolName).toBe("read");
221
+ expect(tc._status).toBe("done");
222
+ expect(tc.isError).toBe(false);
223
+ });
224
+
225
+ it("toolResult isError → InternalToolCall failed", () => {
226
+ writeJsonl([
227
+ headerLine(),
228
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
229
+ assistantEntry([
230
+ { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "false" } },
231
+ ]),
232
+ toolResultEntry("call-1", "bash", { isError: true }),
233
+ ]);
234
+ const rec = reconstructFromFile(filePath);
235
+ expect(rec!.turns[0].toolCalls[0]._status).toBe("failed");
236
+ expect(rec!.turns[0].toolCalls[0].isError).toBe(true);
237
+ });
238
+
239
+ it("孤儿 toolResult(无匹配 toolCall)→ 丢弃,不崩", () => {
240
+ writeJsonl([
241
+ headerLine(),
242
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
243
+ assistantEntry([{ type: "text", text: "ok" }]),
244
+ toolResultEntry("nonexistent", "read"),
245
+ ]);
246
+ const rec = reconstructFromFile(filePath);
247
+ expect(rec).toBeDefined();
248
+ expect(rec!.turns[0].toolCalls).toHaveLength(0);
249
+ });
250
+ });
251
+
252
+ // ============================================================
253
+ // error / stopReason
254
+ // ============================================================
255
+ describe("error 处理", () => {
256
+ it("stopReason=error → lastError + error 字段", () => {
257
+ writeJsonl([
258
+ headerLine(),
259
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
260
+ assistantEntry([{ type: "text", text: "partial" }], {
261
+ stopReason: "error", errorMessage: "API timeout",
262
+ }),
263
+ ]);
264
+ const rec = reconstructFromFile(filePath);
265
+ expect(rec!.lastError).toBe("API timeout");
266
+ expect(rec!.error).toBe("API timeout");
267
+ expect(rec!.status).toBe("failed"); // error stopReason → failed
268
+ });
269
+
270
+ it("stopReason=aborted 无 errorMessage → lastError = 'aborted'", () => {
271
+ writeJsonl([
272
+ headerLine(),
273
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
274
+ assistantEntry([{ type: "text", text: "" }], { stopReason: "aborted" }),
275
+ ]);
276
+ const rec = reconstructFromFile(filePath);
277
+ expect(rec!.lastError).toBe("aborted");
278
+ expect(rec!.status).toBe("failed");
279
+ });
280
+
281
+ it("前序 error 但最后 stop → lastError 清除(镜像 turn_end 语义),status=done", () => {
282
+ writeJsonl([
283
+ headerLine(),
284
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
285
+ assistantEntry([{ type: "text", text: "oops" }], {
286
+ stopReason: "error", errorMessage: "transient", ts: 1000,
287
+ }),
288
+ assistantEntry([{ type: "text", text: "recovered" }], { stopReason: "stop", ts: 2000 }),
289
+ ]);
290
+ const rec = reconstructFromFile(filePath);
291
+ expect(rec!.lastError).toBeUndefined(); // 后续 stop 清除了 error
292
+ expect(rec!.status).toBe("done");
293
+ expect(rec!.result).toBe("oops\n\nrecovered");
294
+ });
295
+ });
296
+
297
+ // ============================================================
298
+ // eventLog 派生
299
+ // ============================================================
300
+ describe("eventLog 派生", () => {
301
+ it("tool_start + tool_end + turn_end 条目", () => {
302
+ writeJsonl([
303
+ headerLine(),
304
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
305
+ assistantEntry([
306
+ { type: "toolCall", id: "c1", name: "read", arguments: { path: "/x.ts" } },
307
+ ]),
308
+ toolResultEntry("c1", "read"),
309
+ ]);
310
+ const rec = reconstructFromFile(filePath);
311
+ const types = rec!.eventLog.map((e) => e.type);
312
+ expect(types).toContain("tool_start");
313
+ expect(types).toContain("tool_end");
314
+ expect(types).toContain("turn_end");
315
+ });
316
+ });
317
+
318
+ // ============================================================
319
+ // 防御性降级
320
+ // ============================================================
321
+ describe("防御性降级", () => {
322
+ it("文件缺失 → undefined", () => {
323
+ expect(reconstructFromFile(path.join(tmpDir, "nonexistent.jsonl"))).toBeUndefined();
324
+ });
325
+
326
+ it("空文件 → undefined", () => {
327
+ fs.writeFileSync(filePath, "", "utf-8");
328
+ expect(reconstructFromFile(filePath)).toBeUndefined();
329
+ });
330
+
331
+ it("缺 identity custom entry → undefined", () => {
332
+ writeJsonl([
333
+ headerLine(),
334
+ assistantEntry([{ type: "text", text: "no identity" }]),
335
+ ]);
336
+ expect(reconstructFromFile(filePath)).toBeUndefined();
337
+ });
338
+
339
+ it("有 identity 但无 assistant message → undefined", () => {
340
+ writeJsonl([
341
+ headerLine(),
342
+ identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }),
343
+ ]);
344
+ expect(reconstructFromFile(filePath)).toBeUndefined();
345
+ });
346
+
347
+ it("损坏 JSON 行跳过,合法行仍解析", () => {
348
+ const fd = fs.openSync(filePath, "w");
349
+ fs.writeSync(fd, `${JSON.stringify(headerLine())}\n`);
350
+ fs.writeSync(fd, "THIS IS NOT JSON\n");
351
+ fs.writeSync(fd, `${JSON.stringify(identityEntry({ id: "r1", agent: "w", mode: "sync", task: "t", startedAt: 100 }))}\n`);
352
+ fs.writeSync(fd, `${JSON.stringify(assistantEntry([{ type: "text", text: "survived" }]))}\n`);
353
+ fs.closeSync(fd);
354
+ const rec = reconstructFromFile(filePath);
355
+ expect(rec).toBeDefined();
356
+ expect(rec!.result).toBe("survived");
357
+ });
358
+ });
359
+ });
@@ -0,0 +1,314 @@
1
+ // src/__tests__/session-runner-schema-env.test.ts
2
+ //
3
+ // Wave 2 (issue #3): schemaEnv bridge 测试。
4
+ //
5
+ // 覆盖 test-matrix 用例:
6
+ // - T3.9 (boundary): schemaEnv 透传——传入时 childEnv 含 PI_WORKFLOW_SCHEMA
7
+ // - T3.11 (state): schemaEnv 不传 → childEnv 无 PI_WORKFLOW_SCHEMA(BC-6 tool 层不变)
8
+ // - T3.16 (NFR-compatibility): schemaEnv 不传时 BC-6 childEnv 等价——不传时与合并前
9
+ // 行为一致,不注入 PI_WORKFLOW_SCHEMA → structured-output tool 不注册
10
+ //
11
+ // 测试策略:
12
+ // - applySchemaEnvToChildEnv 纯函数单测(不依赖 runSpawn/spawn mock)
13
+ // - runSpawn 集成测试:通过 mock spawn 拦截 childEnv,验证 schemaEnv 实际注入
14
+ //
15
+ // D-A6: schemaEnv 经 RunOptions 透传到 runSpawn childEnv。
16
+ // BC-6: tool 层 execute 不传 schemaEnv → childEnv 不设 PI_WORKFLOW_SCHEMA → 行为不变。
17
+
18
+ import type { PassThrough } from "node:stream";
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+
22
+ // ── mock modules (与 run-spawn-integration.test.ts 同模式) ──
23
+
24
+ vi.mock("node:child_process", async () => {
25
+ const { EventEmitter } = await import("node:events");
26
+ const { PassThrough } = await import("node:stream");
27
+
28
+ class FakeChild extends EventEmitter {
29
+ pid = 12345;
30
+ stdout = new PassThrough();
31
+ stderr = new PassThrough();
32
+ killed = false;
33
+ killSignal: string | undefined;
34
+ kill(sig?: string): boolean {
35
+ this.killed = true;
36
+ this.killSignal = sig;
37
+ return true;
38
+ }
39
+ }
40
+
41
+ return {
42
+ spawn: vi.fn(() => new FakeChild()),
43
+ execFileSync: vi.fn(() => ""),
44
+ };
45
+ });
46
+
47
+ vi.mock("node:fs", async () => {
48
+ const actual = await import("node:fs");
49
+ return {
50
+ default: {
51
+ ...actual,
52
+ mkdirSync: vi.fn(),
53
+ existsSync: vi.fn(() => false),
54
+ appendFileSync: vi.fn(),
55
+ writeFileSync: vi.fn(),
56
+ readdirSync: vi.fn(() => []),
57
+ },
58
+ mkdirSync: vi.fn(),
59
+ existsSync: vi.fn(() => false),
60
+ appendFileSync: vi.fn(),
61
+ writeFileSync: vi.fn(),
62
+ readdirSync: vi.fn(() => []),
63
+ promises: actual.promises,
64
+ };
65
+ });
66
+
67
+ vi.mock("../alive-store.ts", () => ({
68
+ writeAliveMarker: vi.fn(),
69
+ }));
70
+
71
+ vi.mock("../temp-prompt.ts", () => ({
72
+ writePromptToTempFile: vi.fn(async (agent: string) => {
73
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
74
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
75
+ }),
76
+ cleanupTempPrompt: vi.fn(async () => {}),
77
+ }));
78
+
79
+ import { execFileSync, spawn } from "node:child_process";
80
+ import * as fs from "node:fs";
81
+
82
+ import { createRecord } from "../execution-record.ts";
83
+ import {
84
+ applySchemaEnvToChildEnv,
85
+ type RunOptions,
86
+ runSpawn,
87
+ type SessionRunnerContext,
88
+ } from "../session-runner.ts";
89
+
90
+ const mockSpawn = vi.mocked(spawn);
91
+ const mockExec = vi.mocked(execFileSync);
92
+ const mockExistsSync = vi.mocked(fs.existsSync);
93
+
94
+ interface FakeChild {
95
+ pid: number;
96
+ stdout: PassThrough;
97
+ stderr: PassThrough;
98
+ killed: boolean;
99
+ killSignal: string | undefined;
100
+ kill(sig?: string): boolean;
101
+ emit(event: string, ...args: unknown[]): boolean;
102
+ }
103
+
104
+ function getLastSpawnedChild(): FakeChild {
105
+ const result = mockSpawn.mock.results.at(-1);
106
+ if (!result) throw new Error("spawn was not called yet");
107
+ return result.value as unknown as FakeChild;
108
+ }
109
+
110
+ function getLastSpawnEnv(): Record<string, string | undefined> {
111
+ return mockSpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string | undefined> ?? {};
112
+ }
113
+
114
+ /**
115
+ * 等待 runSpawn 内部调到 spawn。
116
+ * runSpawn 是 async,spawn 在 writePromptToTempFile await 之后才调。
117
+ */
118
+ async function waitForSpawn(timeoutMs = 1000): Promise<void> {
119
+ const start = Date.now();
120
+ while (mockSpawn.mock.results.length === 0) {
121
+ if (Date.now() - start > timeoutMs) {
122
+ throw new Error(`spawn was not called within ${timeoutMs}ms`);
123
+ }
124
+ await new Promise((r) => setTimeout(r, 5));
125
+ }
126
+ }
127
+
128
+ // ── 公共 fixture ──
129
+
130
+ function makeRecord() {
131
+ return createRecord("test-1", {
132
+ agent: "general-purpose",
133
+ model: "test/model",
134
+ mode: "sync",
135
+ task: "test task",
136
+ startedAt: Date.now(),
137
+ rootSessionId: "s1",
138
+ parentRecordId: undefined,
139
+ depth: 0,
140
+ });
141
+ }
142
+
143
+ function makeRunOpts(overrides: Partial<RunOptions> = {}): RunOptions {
144
+ return {
145
+ resolved: { model: { provider: "test", id: "model" }, thinkingLevel: undefined },
146
+ agentConfig: undefined,
147
+ appendSystemPrompt: undefined,
148
+ skillPath: undefined,
149
+ schema: undefined,
150
+ maxTurns: undefined,
151
+ graceTurns: undefined,
152
+ signal: undefined,
153
+ onEvent: undefined,
154
+ ...overrides,
155
+ };
156
+ }
157
+
158
+ function makeCtx(): SessionRunnerContext {
159
+ return {
160
+ cwd: "/fake/cwd",
161
+ agentDir: "/fake/agent",
162
+ skillDirs: [],
163
+ mainCwd: "/fake/cwd",
164
+ };
165
+ }
166
+
167
+ // ── applySchemaEnvToChildEnv 纯函数单测 ──
168
+
169
+ describe("applySchemaEnvToChildEnv (T3.9/T3.11/T3.16)", () => {
170
+ // T3.11: schemaEnv 不传 → childEnv 无 PI_WORKFLOW_SCHEMA
171
+ it("T3.11: schemaEnv 不传时 childEnv 不含 PI_WORKFLOW_SCHEMA(BC-6)", () => {
172
+ const childEnv: Record<string, string | undefined> = { PATH: "/usr/bin" };
173
+ applySchemaEnvToChildEnv(childEnv, undefined);
174
+ expect(childEnv).not.toHaveProperty("PI_WORKFLOW_SCHEMA");
175
+ expect(childEnv.PATH).toBe("/usr/bin"); // 其他 key 不受影响
176
+ });
177
+
178
+ // T3.16: schemaEnv 不传时 BC-6 childEnv 等价——合并前后行为一致
179
+ it("T3.16: schemaEnv 不传时 childEnv 等价于合并前(BC-6,不注入 PI_WORKFLOW_SCHEMA)", () => {
180
+ const childEnv: Record<string, string | undefined> = {};
181
+ applySchemaEnvToChildEnv(childEnv, undefined);
182
+ // 不传 schemaEnv 时,childEnv 应与调用前完全一致(不含 PI_WORKFLOW_SCHEMA)
183
+ expect(Object.keys(childEnv)).toHaveLength(0);
184
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBeUndefined();
185
+ });
186
+
187
+ // T3.16 补充: schemaEnv 为空串也不注入(空串不是有效 schema)
188
+ it("T3.16 补充: schemaEnv 为空串时不注入(false-ish 语义)", () => {
189
+ const childEnv: Record<string, string | undefined> = {};
190
+ applySchemaEnvToChildEnv(childEnv, "");
191
+ expect(childEnv).not.toHaveProperty("PI_WORKFLOW_SCHEMA");
192
+ });
193
+
194
+ // T3.9: schemaEnv 传入 → childEnv 含 PI_WORKFLOW_SCHEMA
195
+ it("T3.9: schemaEnv 传入时 childEnv 含 PI_WORKFLOW_SCHEMA", () => {
196
+ const childEnv: Record<string, string | undefined> = {};
197
+ const schemaJson = '{"type":"object","properties":{"x":{"type":"number"}}}';
198
+ applySchemaEnvToChildEnv(childEnv, schemaJson);
199
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBe(schemaJson);
200
+ });
201
+
202
+ // T3.9 补充: schemaEnv 值为复杂 JSON 字符串时正确透传
203
+ it("T3.9 补充: schemaEnv 值为复杂 JSON 字符串时完整透传", () => {
204
+ const childEnv: Record<string, string | undefined> = {};
205
+ const schemaJson = JSON.stringify({
206
+ type: "object",
207
+ properties: {
208
+ name: { type: "string" },
209
+ age: { type: "number", minimum: 0 },
210
+ },
211
+ required: ["name"],
212
+ });
213
+ applySchemaEnvToChildEnv(childEnv, schemaJson);
214
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBe(schemaJson);
215
+ // 验证可以 parse 回原始结构
216
+ expect(() => JSON.parse(childEnv.PI_WORKFLOW_SCHEMA!)).not.toThrow();
217
+ });
218
+
219
+ // T3.9 补充: schemaEnv 与已有 key 不冲突
220
+ it("T3.9 补充: schemaEnv 注入不覆盖 childEnv 已有 key", () => {
221
+ const childEnv: Record<string, string | undefined> = {
222
+ PATH: "/usr/bin",
223
+ HOME: "/home/user",
224
+ };
225
+ applySchemaEnvToChildEnv(childEnv, '{"x":1}');
226
+ expect(childEnv.PATH).toBe("/usr/bin");
227
+ expect(childEnv.HOME).toBe("/home/user");
228
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBe('{"x":1}');
229
+ });
230
+ });
231
+
232
+ // ── runSpawn 集成测试:schemaEnv 经 RunOptions → childEnv ──
233
+
234
+ describe("runSpawn schemaEnv childEnv 注入 (T3.9/T3.11)", () => {
235
+ beforeEach(() => {
236
+ vi.clearAllMocks();
237
+ mockExistsSync.mockReturnValue(false);
238
+ mockExec.mockReturnValue("");
239
+ });
240
+
241
+ afterEach(() => {
242
+ vi.restoreAllMocks();
243
+ });
244
+
245
+ // T3.11: schemaEnv 不传 → childEnv 的 PI_WORKFLOW_SCHEMA 保持 process.env 原值
246
+ // BC-6: applySchemaEnvToChildEnv 不注入新值,但 process.env 可能已有此 key(子进程继承父环境)。
247
+ // 验证点: 不传 schemaEnv 时我们的代码不修改 PI_WORKFLOW_SCHEMA。
248
+ it("T3.11 (integration): RunOptions 无 schemaEnv → childEnv 继承 process.env 原值(BC-6)", async () => {
249
+ const record = makeRecord();
250
+ const opts = makeRunOpts({ schemaEnv: undefined });
251
+ const ctx = makeCtx();
252
+
253
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
254
+ await waitForSpawn();
255
+ const childEnv = getLastSpawnEnv();
256
+ // BC-6: schemaEnv 未传入 → PI_WORKFLOW_SCHEMA 应为 process.env 原值(我们的代码不注入)
257
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBe(process.env.PI_WORKFLOW_SCHEMA);
258
+
259
+ const child = getLastSpawnedChild();
260
+ child.emit("close", 0);
261
+ await resultPromise;
262
+ });
263
+
264
+ // T3.9: schemaEnv 传入 → childEnv 含 PI_WORKFLOW_SCHEMA
265
+ it("T3.9 (integration): RunOptions 有 schemaEnv → childEnv 含 PI_WORKFLOW_SCHEMA", async () => {
266
+ const record = makeRecord();
267
+ const schemaJson = '{"type":"object","properties":{"result":{"type":"string"}}}';
268
+ const opts = makeRunOpts({ schemaEnv: schemaJson });
269
+ const ctx = makeCtx();
270
+
271
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
272
+ await waitForSpawn();
273
+ const childEnv = getLastSpawnEnv();
274
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBe(schemaJson);
275
+
276
+ // 关闭子进程
277
+ const child = getLastSpawnedChild();
278
+ child.emit("close", 0);
279
+ await resultPromise;
280
+ });
281
+
282
+ // T3.9 + fork: schemaEnv 与 fork env 共存时不冲突
283
+ it("T3.9 + fork: schemaEnv 与 fork depth env 共存不冲突", async () => {
284
+ const record = createRecord("test-fork-1", {
285
+ agent: "general-purpose",
286
+ model: "test/model",
287
+ mode: "sync",
288
+ task: "test task",
289
+ startedAt: Date.now(),
290
+ rootSessionId: "s1",
291
+ parentRecordId: undefined,
292
+ depth: 1,
293
+ });
294
+ const schemaJson = '{"type":"object"}';
295
+ const opts = makeRunOpts({
296
+ schemaEnv: schemaJson,
297
+ fork: true,
298
+ parentForkDepth: 0,
299
+ });
300
+ const ctx = makeCtx();
301
+
302
+ const resultPromise = runSpawn(record, "test task", opts, ctx);
303
+ await waitForSpawn();
304
+ const childEnv = getLastSpawnEnv();
305
+ // fork depth env 应存在
306
+ expect(childEnv.PI_SUBAGENT_FORK_DEPTH).toBe("1");
307
+ // schemaEnv 也应存在
308
+ expect(childEnv.PI_WORKFLOW_SCHEMA).toBe(schemaJson);
309
+
310
+ const child = getLastSpawnedChild();
311
+ child.emit("close", 0);
312
+ await resultPromise;
313
+ });
314
+ });