@zhushanwen/pi-subagent-workflow 0.1.0 → 0.3.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 (130) hide show
  1. package/README.md +56 -0
  2. package/agents/context-builder.md +1 -3
  3. package/agents/explorer.md +27 -0
  4. package/agents/oracle.md +2 -2
  5. package/agents/orchestrator.md +48 -0
  6. package/agents/planner.md +1 -3
  7. package/agents/researcher.md +0 -2
  8. package/agents/reviewer.md +2 -2
  9. package/agents/worker.md +0 -2
  10. package/package.json +5 -3
  11. package/skills/workflow-script-format/SKILL.md +6 -6
  12. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  13. package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
  14. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  15. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  16. package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
  17. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  18. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  19. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  20. package/src/execution/__tests__/execute-options-mapper.test.ts +41 -9
  21. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  22. package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  24. package/src/execution/__tests__/host-mode.test.ts +87 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  26. package/src/execution/__tests__/list-component.test.ts +1 -1
  27. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  28. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  29. package/src/execution/__tests__/record-store.test.ts +86 -2
  30. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  31. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  32. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  33. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  34. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  35. package/src/execution/__tests__/sdk-contract.test.ts +5 -2
  36. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  37. package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
  38. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  39. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  40. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  41. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  42. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  43. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  44. package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
  45. package/src/execution/__tests__/tool-action.test.ts +27 -5
  46. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  47. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  48. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  49. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  50. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  51. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  52. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  53. package/src/execution/agent-registry.ts +1 -1
  54. package/src/execution/agent-result-mapper.ts +4 -1
  55. package/src/execution/channel-registry-access.ts +138 -0
  56. package/src/execution/concurrency-pool.ts +38 -6
  57. package/src/execution/dialog-queue.ts +329 -0
  58. package/src/execution/execute-options-mapper.ts +21 -4
  59. package/src/execution/execution-record.ts +5 -0
  60. package/src/execution/finalize-record.ts +160 -0
  61. package/src/execution/get-state-handshake.ts +104 -0
  62. package/src/execution/host-mode.ts +52 -0
  63. package/src/execution/manifest-store.ts +206 -0
  64. package/src/execution/notifier.ts +5 -1
  65. package/src/execution/path-encoding.ts +18 -0
  66. package/src/execution/pi-invocation.ts +1 -1
  67. package/src/execution/record-store.ts +110 -2
  68. package/src/execution/session-file-gc.ts +25 -3
  69. package/src/execution/session-reconstructor.ts +11 -0
  70. package/src/execution/session-runner.ts +228 -32
  71. package/src/execution/spawn-event-adapter.ts +219 -6
  72. package/src/execution/stdin-writer.ts +106 -0
  73. package/src/execution/stream-sink.ts +83 -0
  74. package/src/execution/subagent-service.ts +230 -235
  75. package/src/execution/subprocess-agent-runner.ts +16 -4
  76. package/src/execution/types.ts +23 -3
  77. package/src/execution/ui-channels.ts +216 -0
  78. package/src/execution/ui-interaction-model.ts +48 -0
  79. package/src/execution/ui-request-handler-factory.ts +175 -0
  80. package/src/execution/ui-request-observability.ts +77 -0
  81. package/src/execution/ui-request-queue.ts +168 -0
  82. package/src/index.ts +101 -4
  83. package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
  84. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
  85. package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
  86. package/src/interface/command-actions.ts +77 -0
  87. package/src/interface/commands.ts +40 -4
  88. package/src/interface/format.ts +2 -0
  89. package/src/interface/gui-mappers.ts +83 -0
  90. package/src/interface/helpers.ts +52 -9
  91. package/src/interface/list-component.ts +3 -1
  92. package/src/interface/subagent-actions.ts +44 -24
  93. package/src/interface/subagent-tool.ts +56 -24
  94. package/src/interface/subagents.ts +45 -5
  95. package/src/interface/tool-render.ts +16 -5
  96. package/src/interface/tool-workflow-script.ts +113 -15
  97. package/src/interface/tool-workflow.ts +92 -34
  98. package/src/interface/views/WorkflowsView.ts +13 -4
  99. package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
  100. package/src/interface/views/detail-content.ts +20 -0
  101. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
  102. package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
  103. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
  104. package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
  105. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
  106. package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
  107. package/src/orchestration/agent-opts-resolver.ts +11 -2
  108. package/src/orchestration/error-recovery.ts +131 -23
  109. package/src/orchestration/execute-agent-call.ts +12 -3
  110. package/src/orchestration/jsonl-run-store.ts +10 -0
  111. package/src/orchestration/lifecycle.ts +1 -1
  112. package/src/orchestration/models/agent-call.ts +7 -0
  113. package/src/orchestration/models/ports.ts +15 -2
  114. package/src/orchestration/models/run-spec.ts +6 -0
  115. package/src/orchestration/models/trace.ts +1 -0
  116. package/src/orchestration/models/types.ts +19 -0
  117. package/src/orchestration/node-ops.ts +2 -0
  118. package/src/orchestration/worker-script-builder.ts +1 -0
  119. package/workflows/README.md +58 -0
  120. package/workflows/chain.js +107 -0
  121. package/workflows/map-reduce.js +142 -0
  122. package/workflows/parallel.js +131 -0
  123. package/workflows/scatter-gather.js +146 -0
  124. package/agents/scout.md +0 -17
  125. package/examples/README.md +0 -43
  126. package/examples/chain.example.js +0 -92
  127. package/examples/map-reduce.example.js +0 -99
  128. package/examples/parallel.example.js +0 -82
  129. package/examples/scatter-gather.example.js +0 -106
  130. package/src/interface/gui-adapter.ts +0 -136
@@ -14,36 +14,22 @@
14
14
  // - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。
15
15
  // - temp-prompt → mock(writePromptToTempFile 返回固定路径,消除 fake-timers flaky)。
16
16
  // - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。
17
+ //
18
+ // mock 工厂 + FakeChild class + 工具函数(lastSpawnedChild/waitForSpawn/emitStdoutLine/
19
+ // sessionHeader/makeRecord/makeOpts/makeCtx)抽到 helpers/spawn-mock.ts,与
20
+ // run-spawn-edges.test.ts / run-spawn-rpc-mode.test.ts 三文件共享。vi.mock 工厂内用
21
+ // `await import("./helpers/spawn-mock.ts")` 取回 FakeChild(绕开 vitest 的 hoisting 限制——
22
+ // 工厂函数体不能引用顶层 import 变量,但 async 工厂内的 await import 是运行时求值)。
17
23
 
18
- import type { PassThrough } from "node:stream";
24
+ import { execFileSync, spawn } from "node:child_process";
25
+ import * as fs from "node:fs";
19
26
 
20
27
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
28
 
22
- // ── mock modules ──
23
- //
24
- // vitest 会把 vi.mock 提升到文件顶部(早于其他 import / 声明)。mock 工厂若要引用
25
- // FakeChild,需在工厂内部 import(async 工厂可用 await import),而非引用顶部
26
- // 顶层 import(它们在 vi.mock 执行时尚未绑定)。
29
+ // ── mock modules(工厂体共享自 helpers/spawn-mock.ts;vi.mock 必须各文件独立声明)──
27
30
 
28
31
  vi.mock("node:child_process", async () => {
29
- const { EventEmitter } = await import("node:events");
30
- const { PassThrough } = await import("node:stream");
31
-
32
- // FakeChild:模拟 ChildProcess(EventEmitter + PassThrough streams)。
33
- // 测试通过 mockSpawn.mock.results.at(-1).value 取回实例,控制 emit data/close/error 时序。
34
- class FakeChild extends EventEmitter {
35
- pid = 12345;
36
- stdout = new PassThrough();
37
- stderr = new PassThrough();
38
- killed = false;
39
- killSignal: string | undefined;
40
- kill(sig?: string): boolean {
41
- this.killed = true;
42
- this.killSignal = sig;
43
- return true;
44
- }
45
- }
46
-
32
+ const { FakeChild } = await import("./helpers/spawn-mock.ts");
47
33
  return {
48
34
  spawn: vi.fn(() => new FakeChild()),
49
35
  execFileSync: vi.fn(() => ""), // buildEnvBlock 的 git branch 调用,返回空避免副作用
@@ -62,13 +48,11 @@ vi.mock("node:fs", async () => {
62
48
  writeFileSync: vi.fn(),
63
49
  readdirSync: vi.fn(() => []),
64
50
  },
65
- // 具名导出与 default 保持一致
66
51
  mkdirSync: vi.fn(),
67
52
  existsSync: vi.fn(() => false),
68
53
  appendFileSync: vi.fn(),
69
54
  writeFileSync: vi.fn(),
70
55
  readdirSync: vi.fn(() => []),
71
- // promises 保留真实实现——temp-prompt 已被 mock(见下方 vi.mock),不再触发真实 I/O
72
56
  promises: actual.promises,
73
57
  };
74
58
  });
@@ -80,9 +64,7 @@ vi.mock("../alive-store.ts", () => ({
80
64
  // temp-prompt:mock 掉真实 fs.promises I/O(mkdtemp/writeFile/rm)。
81
65
  // 原先保留真实实现导致 fake-timers 测试偶发 flaky——writePromptToTempFile 的真实异步
82
66
  // I/O 在 CI 慢机器上无法在 advanceTimersByTimeAsync 的有限步数内 resolve,spawn 永不触发。
83
- // runSpawn 只消费返回的 filePath 字符串(传给 --append-system-prompt),无需真实文件。
84
67
  vi.mock("../temp-prompt.ts", () => ({
85
- // 文件名规则与真实实现对齐(safeName:非 \w.- 替换为 _),保持 spawn args 断言稳定
86
68
  writePromptToTempFile: vi.fn(async (agent: string) => {
87
69
  const safeName = agent.replace(/[^\w.-]+/g, "_");
88
70
  return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
@@ -90,11 +72,18 @@ vi.mock("../temp-prompt.ts", () => ({
90
72
  cleanupTempPrompt: vi.fn(async () => {}),
91
73
  }));
92
74
 
93
- import { execFileSync, spawn } from "node:child_process";
94
- import * as fs from "node:fs";
95
-
96
- import { createRecord } from "../execution-record.ts";
97
- import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
75
+ import { runSpawn } from "../session-runner.ts";
76
+ import {
77
+ emitStdoutLine,
78
+ type FakeChild,
79
+ lastSpawnedChild as lastSpawnedChildOf,
80
+ makeCtx,
81
+ makeOpts,
82
+ makeRecord,
83
+ mockSessionFileExists as mockSessionFileExistsOf,
84
+ sessionHeader,
85
+ waitForSpawn as waitForSpawnOf,
86
+ } from "./helpers/spawn-mock.ts";
98
87
 
99
88
  const mockSpawn = vi.mocked(spawn);
100
89
  const mockExec = vi.mocked(execFileSync);
@@ -102,124 +91,10 @@ const mockExistsSync = vi.mocked(fs.existsSync);
102
91
  const mockAppendFileSync = vi.mocked(fs.appendFileSync);
103
92
  const mockMkdirSync = vi.mocked(fs.mkdirSync);
104
93
 
105
- /**
106
- * spawn mock 返回的 fake child 类型。
107
- * 由于 FakeChild 定义在 vi.mock 工厂内部(作用域隔离),此处用结构子集类型描述,
108
- * 测试代码通过此类型访问 stdout/stderr/kill 等成员。
109
- */
110
- interface FakeChild {
111
- pid: number;
112
- stdout: PassThrough;
113
- stderr: PassThrough;
114
- killed: boolean;
115
- killSignal: string | undefined;
116
- kill(sig?: string): boolean;
117
- emit(event: string, ...args: unknown[]): boolean;
118
- }
119
-
120
- /** 从最近一次 spawn 调用取回返回的 FakeChild(测试控制器)。 */
121
- function lastSpawnedChild(): FakeChild {
122
- const result = mockSpawn.mock.results.at(-1);
123
- if (!result) throw new Error("spawn was not called yet");
124
- return result.value as FakeChild;
125
- }
126
-
127
- /**
128
- * 等待 runSpawn 内部调到 spawn(拿到 child 控制器)。
129
- *
130
- * runSpawn 是 async,spawn 在 mkdirSync + writePromptToTempFile 之后才调(均有微任务/
131
- * I/O 延迟)。用 setInterval 轮询 mockSpawn.mock.results,比 vi.waitFor 在该 vitest 版本
132
- * 下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
133
- */
134
- async function waitForSpawn(timeoutMs = 1000): Promise<void> {
135
- const start = Date.now();
136
- while (mockSpawn.mock.results.length === 0) {
137
- if (Date.now() - start > timeoutMs) {
138
- throw new Error(`spawn was not called within ${timeoutMs}ms`);
139
- }
140
- await new Promise((r) => setTimeout(r, 5));
141
- }
142
- }
143
-
144
- // ============================================================
145
- // 辅助:向 stdout 写一行(自动补换行,runSpawn 按 \n split 行)
146
- // ============================================================
147
-
148
- function emitStdoutLine(child: FakeChild, obj: Record<string, unknown>): void {
149
- child.stdout.write(`${JSON.stringify(obj)}\n`);
150
- }
151
-
152
- /** 构造 session header 行(stdout 首行)。 */
153
- function sessionHeader(id = "sess-abc"): Record<string, unknown> {
154
- return {
155
- type: "session",
156
- id,
157
- timestamp: "2026-07-03T12-00-00-000Z",
158
- cwd: "/tmp/test",
159
- };
160
- }
161
-
162
- // ============================================================
163
- // 辅助:构造最小合法的 record / opts / ctx
164
- // ============================================================
165
-
166
- function makeRecord() {
167
- return createRecord("run-1", {
168
- agent: "general-purpose",
169
- model: "test-model",
170
- mode: "sync",
171
- task: "do something",
172
- startedAt: 1_000_000,
173
- rootSessionId: "root-session",
174
- parentRecordId: undefined,
175
- depth: 0,
176
- });
177
- }
178
-
179
- function makeOpts(overrides: Partial<RunOptions> = {}): RunOptions {
180
- return {
181
- resolved: {
182
- model: {
183
- id: "test-model",
184
- name: "Test Model",
185
- provider: "test",
186
- reasoning: false,
187
- },
188
- thinkingLevel: undefined,
189
- },
190
- agentConfig: undefined,
191
- appendSystemPrompt: undefined,
192
- skillPath: undefined,
193
- schema: undefined,
194
- maxTurns: undefined,
195
- graceTurns: undefined,
196
- signal: undefined,
197
- onEvent: undefined,
198
- ...overrides,
199
- };
200
- }
201
-
202
- function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
203
- return {
204
- cwd: "/tmp/test",
205
- agentDir: "/tmp/test/agents",
206
- skillDirs: [],
207
- mainCwd: "/tmp/test",
208
- mainSessionFile: undefined,
209
- ...overrides,
210
- };
211
- }
212
-
213
- /**
214
- * 让 sessionFile 存在校验通过——
215
- * runSpawn 在进程退出后用 existsSync(record.sessionFile) 判断是否补写 identity。
216
- * 默认 mock existsSync 返回 false(兜底查找),此 helper 在指定路径返回 true。
217
- */
218
- function mockSessionFileExists(sessionFilePath: string): void {
219
- mockExistsSync.mockImplementation((p: unknown) => {
220
- return String(p) === sessionFilePath;
221
- });
222
- }
94
+ // 绑定到本文件 mockSpawn 的 lastSpawnedChild/waitForSpawn(需读 mockSpawn.mock.results)
95
+ const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
96
+ const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
97
+ const mockSessionFileExists = (p: string): void => mockSessionFileExistsOf(mockExistsSync, p);
223
98
 
224
99
  // ============================================================
225
100
  // 测试
@@ -892,6 +767,65 @@ describe("runSpawn", () => {
892
767
  });
893
768
  });
894
769
 
770
+ // ── 15. stdin prompt 注入 ──
771
+ //
772
+ // [RPC prompt 修复] pi runRpcMode 只通过 stdin RpcCommand 驱动——positional task arg
773
+ // / -p flag 在 rpc mode 下被 resolveAppMode 无视。runSpawn 必须在 spawn 后主动写
774
+ // {type:"prompt",message:<task>} 到 child.stdin,否则子进程阻塞、totalTokens 恒 0。
775
+ // sendPromptCommand 在 spawn + setEncoding 后同步执行,waitForSpawn 拿到 child 时
776
+ // 命令已在 stdin 缓冲。PassThrough.write 无 reader 时缓冲全部数据,可事后读出断言。
777
+ describe("stdin prompt 注入", () => {
778
+ it("spawn 后向 stdin 写一行 {type:prompt} 且 message 含 task 文本", async () => {
779
+ const record = makeRecord();
780
+ const taskText = "Task: hello-prompt-injection";
781
+ const promise = runSpawn(record, taskText, makeOpts(), makeCtx());
782
+
783
+ await waitForSpawn();
784
+ const child = lastSpawnedChild();
785
+
786
+ // sendPromptCommand 已同步执行——PassThrough 缓冲了写入的命令,读出来断言。
787
+ // pause() 让 PassThrough 切到暂停模式(默认 flow 模式下数据缓冲在内部),
788
+ // 然后 read() 取出全部已缓冲内容。
789
+ child.stdin.pause();
790
+ const buffered = child.stdin.read()?.toString() ?? "";
791
+
792
+ // 收尾:让 runSpawn resolve(避免悬挂)
793
+ emitStdoutLine(child, sessionHeader());
794
+ child.stdout.end();
795
+ child.stderr.end();
796
+ child.emit("close", 0);
797
+ await promise;
798
+
799
+ // 断言:缓冲含合法 JSON,type=prompt,message 含 task 文本
800
+ const lines = buffered.trim().split("\n");
801
+ expect(lines.length).toBeGreaterThanOrEqual(1);
802
+ const cmd = JSON.parse(lines[0]!) as { type: string; message: string; id?: string };
803
+ expect(cmd.type).toBe("prompt");
804
+ expect(cmd.message).toBe(taskText);
805
+ expect(typeof cmd.id).toBe("string");
806
+ });
807
+
808
+ it("child.stdin.destroyed → sendPromptCommand 不抛错(guard 生效)", async () => {
809
+ const record = makeRecord();
810
+ const promise = runSpawn(record, "Task: destroyed-stdin", makeOpts(), makeCtx());
811
+
812
+ await waitForSpawn();
813
+ const child = lastSpawnedChild();
814
+
815
+ // destroy stdin 模拟子进程已关闭输入通道;sendPromptCommand 在 spawn 时已执行过一次
816
+ //(stdin 当时未 destroyed),这里仅验证后续不抛。此用例主要保护 guard 逻辑——
817
+ // 收尾正常 close 即证明无异常抛出中断 runSpawn。
818
+ child.stdin.destroy();
819
+ emitStdoutLine(child, sessionHeader());
820
+ child.stdout.end();
821
+ child.stderr.end();
822
+ child.emit("close", 0);
823
+
824
+ const result = await promise;
825
+ expect(result.success).toBe(true);
826
+ });
827
+ });
828
+
895
829
  // 注:C1(orphan 进程兜底)与 M8(stdout 边界)describe 块已移至 run-spawn-edges.test.ts,
896
830
  // 拆分以保持本文件 < 1000 行(pre-commit hook 限制)。两文件各自独立声明文件级 mock。
897
831
  });
@@ -0,0 +1,193 @@
1
+ // src/__tests__/run-spawn-rpc-mode.test.ts
2
+ //
3
+ // runSpawn 的 RPC mode 集成测试(从 run-spawn-integration.test.ts 拆出,保持该文件 < 1000 行)。
4
+ //
5
+ // 本文件覆盖 FR-4: RPC mode(pi --mode rpc)无 header 场景——record.sessionFile 无法靠
6
+ // stdout header 推导,必须通过 get_state RPC 握手回填。验证修复后的握手逻辑。
7
+ //
8
+ // mock 工厂 + FakeChild + 工具函数共享自 helpers/spawn-mock.ts(详见该文件头注释)。
9
+ // vi.mock 必须各文件独立声明(文件作用域),工厂内用 `await import` 取回 FakeChild。
10
+
11
+ import { execFileSync, spawn } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+
16
+ vi.mock("node:child_process", async () => {
17
+ const { FakeChild } = await import("./helpers/spawn-mock.ts");
18
+ return {
19
+ spawn: vi.fn(() => new FakeChild()),
20
+ execFileSync: vi.fn(() => ""),
21
+ };
22
+ });
23
+
24
+ vi.mock("node:fs", async () => {
25
+ const actual = await import("node:fs");
26
+ return {
27
+ default: {
28
+ ...actual,
29
+ mkdirSync: vi.fn(),
30
+ existsSync: vi.fn(() => false),
31
+ appendFileSync: vi.fn(),
32
+ writeFileSync: vi.fn(),
33
+ readdirSync: vi.fn(() => []),
34
+ },
35
+ mkdirSync: vi.fn(),
36
+ existsSync: vi.fn(() => false),
37
+ appendFileSync: vi.fn(),
38
+ writeFileSync: vi.fn(),
39
+ readdirSync: vi.fn(() => []),
40
+ promises: actual.promises,
41
+ };
42
+ });
43
+
44
+ vi.mock("../alive-store.ts", () => ({
45
+ writeAliveMarker: vi.fn(),
46
+ }));
47
+
48
+ vi.mock("../temp-prompt.ts", () => ({
49
+ writePromptToTempFile: vi.fn(async (agent: string) => {
50
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
51
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
52
+ }),
53
+ cleanupTempPrompt: vi.fn(async () => {}),
54
+ }));
55
+
56
+ import { runSpawn } from "../session-runner.ts";
57
+ import {
58
+ emitStdoutLine,
59
+ type FakeChild,
60
+ lastSpawnedChild as lastSpawnedChildOf,
61
+ makeCtx,
62
+ makeOpts,
63
+ makeRecord,
64
+ mockSessionFileExists as mockSessionFileExistsOf,
65
+ waitForSpawn as waitForSpawnOf,
66
+ } from "./helpers/spawn-mock.ts";
67
+
68
+ const mockSpawn = vi.mocked(spawn);
69
+ const mockExec = vi.mocked(execFileSync);
70
+ const mockExistsSync = vi.mocked(fs.existsSync);
71
+ const mockAppendFileSync = vi.mocked(fs.appendFileSync);
72
+
73
+ // 绑定到本文件 mockSpawn/mockExistsSync 的 helper(需读 mock 状态)
74
+ const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
75
+ const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
76
+ const mockSessionFileExists = (p: string): void => mockSessionFileExistsOf(mockExistsSync, p);
77
+
78
+ // ============================================================
79
+ // 测试
80
+ // ============================================================
81
+
82
+ describe("runSpawn", () => {
83
+ beforeEach(() => {
84
+ vi.clearAllMocks();
85
+ mockExec.mockReturnValue("");
86
+ mockExistsSync.mockReturnValue(false);
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.restoreAllMocks();
91
+ });
92
+
93
+ // ── FR-4: RPC mode 无 header(get_state 握手回填 sessionFile)──
94
+ //
95
+ // RPC mode(pi --mode rpc)不向 stdout 输出 header 行,record.sessionFile 无法靠
96
+ // header 推导,必须通过 get_state RPC 握手回填。json mode 测试 emit sessionHeader()
97
+ // 模拟 header;本组测试不 emit header,靠 get_state response 回填,验证修复后的握手逻辑
98
+ //(握手移出 header 块、spawn 后无条件启动、close handler 主动 settle 不阻塞)。
99
+ describe("RPC mode 无 header(FR-4 get_state 握手)", () => {
100
+ /**
101
+ * 捕获握手发出的 get_state 命令并 emit 对应 response。
102
+ *
103
+ * 握手在 spawn 后发 get_state 到 child.stdin(id 随机)。测试监听 stdin 捕获 id,
104
+ * emit get_state response 到 stdout,经 stdout pump 匹配 get_stateListeners 触发
105
+ * finishHandshake 回填 record.sessionFile。
106
+ */
107
+ function captureAndRespondGetState(
108
+ child: FakeChild,
109
+ sessionFile: string,
110
+ sessionId = "rpc-sess",
111
+ ): void {
112
+ child.stdin.on("data", (data: Buffer | string) => {
113
+ const text = typeof data === "string" ? data : data.toString();
114
+ for (const line of text.split("\n")) {
115
+ if (!line.trim()) continue;
116
+ try {
117
+ const cmd = JSON.parse(line) as { type?: string; id?: string };
118
+ if (cmd.type === "get_state" && cmd.id) {
119
+ emitStdoutLine(child, {
120
+ type: "response",
121
+ command: "get_state",
122
+ success: true,
123
+ id: cmd.id,
124
+ data: { sessionFile, sessionId },
125
+ });
126
+ }
127
+ } catch {
128
+ // 非 JSON 行(prompt 命令等)忽略
129
+ }
130
+ }
131
+ });
132
+ }
133
+
134
+ it("无 header + get_state response 回填 sessionFile → identity 写入成功", async () => {
135
+ const record = makeRecord();
136
+ const promise = runSpawn(record, "Task: rpc-no-header", makeOpts(), makeCtx());
137
+
138
+ await waitForSpawn();
139
+ const child = lastSpawnedChild();
140
+
141
+ const expectedSessionFile =
142
+ "/tmp/test/agents/subagents/--tmp-test--/sessions/rpc-session.jsonl";
143
+ // 进程退出后 existsSync(record.sessionFile) 校验通过 → 补写 identity
144
+ mockSessionFileExists(expectedSessionFile);
145
+ captureAndRespondGetState(child, expectedSessionFile);
146
+
147
+ // 等待 stdin listener 触发 + response 经 stdout pump 处理 → finishHandshake 回填。
148
+ // PassThrough attach data listener 后在 nextTick flush 缓冲,setTimeout(20) 足够覆盖。
149
+ await new Promise((r) => setTimeout(r, 20));
150
+
151
+ // RPC mode:只 emit 事件,不 emit header
152
+ emitStdoutLine(child, { type: "turn_end" });
153
+ child.stdout.end();
154
+ child.emit("close", 0);
155
+
156
+ const result = await promise;
157
+
158
+ expect(result.success).toBe(true);
159
+ expect(record.sessionFile).toBe(expectedSessionFile);
160
+ expect(result.sessionFile).toBe(expectedSessionFile);
161
+ // identity 经握手回填的 sessionFile 写入(不再依赖 sessionHeader 条件)
162
+ expect(mockAppendFileSync).toHaveBeenCalledWith(
163
+ expectedSessionFile,
164
+ expect.stringContaining('"customType":"subagent-identity"'),
165
+ "utf-8",
166
+ );
167
+ });
168
+
169
+ it("无 header + get_state 无响应 → close 主动 settle 不阻塞,identity 不写入", async () => {
170
+ const record = makeRecord();
171
+ const promise = runSpawn(record, "Task: rpc-no-response", makeOpts(), makeCtx());
172
+
173
+ await waitForSpawn();
174
+ const child = lastSpawnedChild();
175
+
176
+ // 消费 stdin 避免背压;不 emit get_state response(模拟握手超时/失败)
177
+ child.stdin.on("data", () => {});
178
+
179
+ emitStdoutLine(child, { type: "turn_end" });
180
+ child.stdout.end();
181
+ child.emit("close", 0);
182
+
183
+ const result = await promise;
184
+
185
+ // close handler 主动 settle,不等握手内部 6s 超时 → 测试不超时(5s 默认上限)
186
+ expect(result.success).toBe(true);
187
+ // 握手未完成 → sessionFile 未回填
188
+ expect(record.sessionFile).toBeUndefined();
189
+ // identity 不写入
190
+ expect(mockAppendFileSync).not.toHaveBeenCalled();
191
+ });
192
+ });
193
+ });
@@ -174,13 +174,14 @@ describe("subagent tool contract [MANDATORY]", () => {
174
174
  mode: "background",
175
175
  subagentId: "bg-test",
176
176
  sessionFile: "/test/session.jsonl",
177
+ details: { slug: "test-slug" },
177
178
  });
178
179
  const ctxModel = { id: "test-model", name: "Test", provider: "test", reasoning: false };
179
180
  const ctx = { model: ctxModel } as object;
180
181
 
181
182
  await capturedExecute!(
182
183
  "call-1",
183
- { action: "start", startParam: { task: "test task" } },
184
+ { action: "start", startParam: { task: "test task", slug: "test-slug" } },
184
185
  undefined,
185
186
  undefined,
186
187
  ctx,
@@ -188,7 +189,7 @@ describe("subagent tool contract [MANDATORY]", () => {
188
189
 
189
190
  expect(mockServiceExecute).toHaveBeenCalledTimes(1);
190
191
  expect(mockServiceExecute).toHaveBeenCalledWith(
191
- expect.objectContaining({ ctxModel }),
192
+ expect.objectContaining({ ctxModel, slug: "test-slug" }),
192
193
  );
193
194
  });
194
195
 
@@ -212,6 +213,7 @@ describe("subagent tool contract [MANDATORY]", () => {
212
213
  mode: "background",
213
214
  subagentId: "bg-fork-wt",
214
215
  sessionFile: "/test/session.jsonl",
216
+ details: { slug: "iso-work" },
215
217
  });
216
218
 
217
219
  await capturedExecute!(
@@ -220,6 +222,7 @@ describe("subagent tool contract [MANDATORY]", () => {
220
222
  action: "start",
221
223
  startParam: {
222
224
  task: "isolated work",
225
+ slug: "iso-work",
223
226
  fork: true,
224
227
  worktree: true,
225
228
  cwd: "/x",
@@ -244,4 +244,50 @@ describe("maybeCleanupExpiredSessionFiles", () => {
244
244
  expect(fs.existsSync(finalized)).toBe(false);
245
245
  expect(fs.existsSync(alive)).toBe(false);
246
246
  });
247
+
248
+ // ---- [F2] manifest .json 清理(仅 records 子目录内)----
249
+ // records 目录布局:subagents/<enc>/records/<id>.json(D-004 cwd 物理隔离)。
250
+ // allowManifestJson 仅在名为 records 的子目录内打开,防止误删 enc 外层的
251
+ // worktrees.json(worktree reaper 依赖的状态文件)。
252
+
253
+ it("[F2] deletes expired manifest .json inside records/ subdir", () => {
254
+ forceCleanupTrigger();
255
+ const manifest = createSessionFile(
256
+ path.join("--Users-x-proj--", "records", "rec-1.json"),
257
+ 31,
258
+ );
259
+ maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
260
+ expect(fs.existsSync(manifest)).toBe(false);
261
+ });
262
+
263
+ it("[F2] preserves manifest .json younger than TTL inside records/", () => {
264
+ forceCleanupTrigger();
265
+ const manifest = createSessionFile(
266
+ path.join("--Users-x-proj--", "records", "rec-young.json"),
267
+ 5,
268
+ );
269
+ maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
270
+ expect(fs.existsSync(manifest)).toBe(true);
271
+ });
272
+
273
+ it("[F2] CRITICAL: does NOT delete worktrees.json at subagents/ root (regression)", () => {
274
+ // 破坏性风险点:worktrees.json 在 agentDir/subagents/worktrees.json(enc 外层第一层)。
275
+ // 暴力匹配所有 .json 会误删 → worktree reaper 失效。allowManifestJson 只在名为 records
276
+ // 的子目录内打开,根层的 worktrees.json 必须保留。
277
+ forceCleanupTrigger();
278
+ const worktrees = createSessionFile("worktrees.json", 31);
279
+ maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
280
+ expect(fs.existsSync(worktrees)).toBe(true);
281
+ });
282
+
283
+ it("[F2] preserves .json.tmp.* inside records/ (recoverTmpFiles owns them)", () => {
284
+ // 跳过 .tmp.:session_start 的 recoverTmpFiles 同步处理 tmp,GC 不重复。
285
+ forceCleanupTrigger();
286
+ const tmp = createSessionFile(
287
+ path.join("--Users-x-proj--", "records", "rec-2.json.tmp.123"),
288
+ 31,
289
+ );
290
+ maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
291
+ expect(fs.existsSync(tmp)).toBe(true);
292
+ });
247
293
  });
@@ -167,6 +167,26 @@ describe("reconstructFromFile", () => {
167
167
  expect(rec!.rootSessionId).toBeUndefined();
168
168
  });
169
169
 
170
+ it("读出 identity 里的 slug", () => {
171
+ writeJsonl([
172
+ headerLine(),
173
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", slug: "extract-urls", startedAt: 100 }),
174
+ assistantEntry([{ type: "text", text: "ok" }]),
175
+ ]);
176
+ const rec = reconstructFromFile(filePath);
177
+ expect(rec!.slug).toBe("extract-urls");
178
+ });
179
+
180
+ it("旧文件 identity 无 slug → 兜底空串(向后兼容)", () => {
181
+ writeJsonl([
182
+ headerLine(),
183
+ identityEntry({ id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 100 }),
184
+ assistantEntry([{ type: "text", text: "ok" }]),
185
+ ]);
186
+ const rec = reconstructFromFile(filePath);
187
+ expect(rec!.slug).toBe("");
188
+ });
189
+
170
190
  it("读出 identity 里的 parentRecordId/depth(递归层级)", () => {
171
191
  writeJsonl([
172
192
  headerLine(),
@@ -58,10 +58,12 @@ vi.mock("../session-file-gc.ts", () => ({
58
58
  }));
59
59
 
60
60
  // mock subagent-service:避免真正构造 SubagentService(它依赖 ModelConfigService 等)
61
- const { mockInitModel, mockInitSession, mockSetModelConfigService, mockSetSubagentService, capturedConstructorArg } =
61
+ const { mockInitModel, mockInitSession, mockSetUiRequestHandler, mockSetModelConfigService, mockSetSubagentService, capturedConstructorArg } =
62
62
  vi.hoisted(() => ({
63
63
  mockInitModel: vi.fn(),
64
64
  mockInitSession: vi.fn(),
65
+ // W3: index.ts session_start 注入 UI handler 时调用
66
+ mockSetUiRequestHandler: vi.fn(),
65
67
  mockSetModelConfigService: vi.fn(),
66
68
  mockSetSubagentService: vi.fn(),
67
69
  capturedConstructorArg: { current: undefined as unknown },
@@ -80,6 +82,8 @@ vi.mock("../model-config-service.ts", () => ({
80
82
  vi.mock("../subagent-service.ts", () => ({
81
83
  SubagentService: class {
82
84
  initSession = mockInitSession;
85
+ // W3: index.ts session_start 注入 UI handler 时调用
86
+ setUiRequestHandler = mockSetUiRequestHandler;
83
87
  constructor(init: unknown) {
84
88
  capturedConstructorArg.current = init;
85
89
  }
@@ -136,6 +140,8 @@ function createMockPi(overrides: Record<string, unknown> = {}): {
136
140
  function createMockCtx(overrides: Record<string, unknown> = {}): Record<string, unknown> {
137
141
  return {
138
142
  cwd: "/home/user/project",
143
+ // [Wave1 #21] mode 必填(与 SDK ExtensionContext 契约一致);默认 tui。
144
+ mode: "tui",
139
145
  modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => false },
140
146
  model: undefined,
141
147
  sessionManager: {