@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 (166) hide show
  1. package/README.md +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -49,7 +49,7 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
49
49
  it("flush 时调 sendMessage 的 options.deliverAs === 'steer'(FR-3/AC-3)", () => {
50
50
  notifier.notify({
51
51
  id: "bg-test-1",
52
- status: "done",
52
+ status: "closed",
53
53
  agent: "explorer",
54
54
  result: "done",
55
55
  startedAt: Date.now() - 1000,
@@ -65,7 +65,7 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
65
65
  it("flush 时 triggerTurn 也必须为 true(让父 agent 立即唤醒)", () => {
66
66
  notifier.notify({
67
67
  id: "bg-test-2",
68
- status: "failed",
68
+ status: "closed",
69
69
  agent: "worker",
70
70
  error: "boom",
71
71
  startedAt: Date.now(),
@@ -100,7 +100,7 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
100
100
  host.isIdle.mockReturnValue(false);
101
101
  notifier.notify({
102
102
  id: "bg-race-1",
103
- status: "done",
103
+ status: "closed",
104
104
  agent: "worker",
105
105
  result: "ok",
106
106
  startedAt: Date.now(),
@@ -131,7 +131,7 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
131
131
  host.isIdle.mockReturnValue(false);
132
132
  notifier.notify({
133
133
  id: "bg-starve-1",
134
- status: "done",
134
+ status: "closed",
135
135
  agent: "worker",
136
136
  result: "ok",
137
137
  startedAt: Date.now(),
@@ -155,7 +155,7 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
155
155
  const legacyNotifier = new BgNotifier(legacyHost);
156
156
  legacyNotifier.notify({
157
157
  id: "bg-legacy-1",
158
- status: "done",
158
+ status: "closed",
159
159
  agent: "worker",
160
160
  result: "ok",
161
161
  startedAt: Date.now(),
@@ -171,7 +171,7 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
171
171
  host.isIdle.mockReturnValue(false);
172
172
  notifier.notify({
173
173
  id: "bg-dispose-1",
174
- status: "done",
174
+ status: "closed",
175
175
  agent: "worker",
176
176
  result: "ok",
177
177
  startedAt: Date.now(),
@@ -183,4 +183,206 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
183
183
  vi.advanceTimersByTime(10_000);
184
184
  expect(host.sendMessageCalls).toHaveLength(0);
185
185
  });
186
- });
186
+ });
187
+
188
+ describe("BgNotifier dedup 按轮次(G1 决策 9:对话模式豁免 60s dedup)", () => {
189
+ let host: ReturnType<typeof makeMockHost>;
190
+ let notifier: BgNotifier;
191
+
192
+ beforeEach(() => {
193
+ host = makeMockHost();
194
+ // hasRunningBackground=false → notify 立即 flush(不排队);dedup 仍在 push 前生效
195
+ notifier = new BgNotifier(host);
196
+ });
197
+
198
+ afterEach(() => {
199
+ notifier.dispose();
200
+ });
201
+
202
+ it("对话模式:同 id 不同 round 的两次 notify 不互相吞(round 参与 dedup key)", () => {
203
+ notifier.notify({
204
+ id: "sa-chat", status: "idle", agent: "w", round: 1, result: "round1",
205
+ startedAt: 1, endedAt: 2,
206
+ });
207
+ notifier.notify({
208
+ id: "sa-chat", status: "idle", agent: "w", round: 2, result: "round2",
209
+ startedAt: 3, endedAt: 4,
210
+ });
211
+
212
+ // MF-1 修复:dedup key=id:round,不同 round 不互相吞
213
+ expect(host.sendMessageCalls).toHaveLength(2);
214
+ });
215
+
216
+ it("同 id 同 round 60s 内第二次被 dedup 吞(防重复通知)", () => {
217
+ notifier.notify({
218
+ id: "sa-dup", status: "idle", agent: "w", round: 1, result: "r",
219
+ startedAt: 1, endedAt: 2,
220
+ });
221
+ notifier.notify({
222
+ id: "sa-dup", status: "idle", agent: "w", round: 1, result: "r",
223
+ startedAt: 3, endedAt: 4,
224
+ });
225
+
226
+ // 第二条被吞(dedup key=sa-dup:1 命中)
227
+ expect(host.sendMessageCalls).toHaveLength(1);
228
+ });
229
+
230
+ it("非 chatMode(round undefined → key=id:0)行为同旧(向后兼容,60s 内同 id 吞)", () => {
231
+ // round undefined → dedup key="sa-once:0",与旧 record.id 单 key 行为一致
232
+ notifier.notify({
233
+ id: "sa-once", status: "closed", agent: "w", result: "done",
234
+ startedAt: 1, endedAt: 2,
235
+ });
236
+ notifier.notify({
237
+ id: "sa-once", status: "closed", agent: "w", result: "done",
238
+ startedAt: 3, endedAt: 4,
239
+ });
240
+
241
+ // 第二条被吞(旧 dedup 行为不变,一次性模式回归)
242
+ expect(host.sendMessageCalls).toHaveLength(1);
243
+ });
244
+ });
245
+
246
+ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile 透传)", () => {
247
+ let host: ReturnType<typeof makeMockHost>;
248
+ let notifier: BgNotifier;
249
+
250
+ beforeEach(() => {
251
+ host = makeMockHost();
252
+ // hasRunningBackground=false → notify 立即 flush,sendMessageCalls 恰 1 条
253
+ notifier = new BgNotifier(host);
254
+ });
255
+
256
+ afterEach(() => {
257
+ notifier.dispose();
258
+ });
259
+
260
+ /** 取唯一一条已发消息的 content(前置断言恰 1 条)。 */
261
+ function sentContent(): string {
262
+ expect(host.sendMessageCalls).toHaveLength(1);
263
+ return (host.sendMessageCalls[0]!.message as { content: string }).content;
264
+ }
265
+
266
+ it("running(轮次通知)+ sessionFile → 末尾追加 \\n\\n 空行分隔的指针行", () => {
267
+ notifier.notify({
268
+ id: "sa-ptr-1", status: "running", agent: "w", round: 1, result: "round1 text",
269
+ sessionFile: "/tmp/sessions/child-1.jsonl",
270
+ startedAt: 1, endedAt: 2,
271
+ });
272
+
273
+ expect(sentContent()).toBe(
274
+ 'Subagent "w" (sa-ptr-1) finished a round. Reply:\nround1 text' +
275
+ "\n\nFull transcript: /tmp/sessions/child-1.jsonl",
276
+ );
277
+ });
278
+
279
+ it("closed 成功文案 + sessionFile → 指针行追加在最终串末尾", () => {
280
+ notifier.notify({
281
+ id: "sa-ptr-2", status: "closed", agent: "w", result: "final result",
282
+ sessionFile: "/tmp/sessions/child-2.jsonl",
283
+ startedAt: 1, endedAt: 2,
284
+ });
285
+
286
+ expect(sentContent()).toBe(
287
+ 'Subagent "w" (sa-ptr-2) completed. Result:\nfinal result' +
288
+ "\n\nFull transcript: /tmp/sessions/child-2.jsonl",
289
+ );
290
+ });
291
+
292
+ it("closed + patchFile + sessionFile → 指针行追加在 patch 提示串末尾(最终串)", () => {
293
+ notifier.notify({
294
+ id: "sa-ptr-3", status: "closed", agent: "w", result: "did work",
295
+ patchFile: "/tmp/patches/sa-ptr-3.patch",
296
+ sessionFile: "/tmp/sessions/child-3.jsonl",
297
+ startedAt: 1, endedAt: 2,
298
+ });
299
+
300
+ const content = sentContent();
301
+ expect(content).toContain("git apply /tmp/patches/sa-ptr-3.patch");
302
+ expect(content.endsWith("\n\nFull transcript: /tmp/sessions/child-3.jsonl")).toBe(true);
303
+ });
304
+
305
+ it("sessionFile 缺失 → 省略整行(running 通知正文与无指针形态逐字节一致)", () => {
306
+ notifier.notify({
307
+ id: "sa-ptr-4", status: "running", agent: "w", round: 2, result: "round2 text",
308
+ startedAt: 1, endedAt: 2,
309
+ });
310
+
311
+ expect(sentContent()).toBe('Subagent "w" (sa-ptr-4) finished a round. Reply:\nround2 text');
312
+ });
313
+
314
+ it("cancelled → 不追加指针行(即使 sessionFile 有值)", () => {
315
+ notifier.notify({
316
+ id: "sa-ptr-5", status: "closed", closedReason: "cancelled", agent: "w",
317
+ sessionFile: "/tmp/sessions/child-5.jsonl",
318
+ startedAt: 1, endedAt: 2,
319
+ });
320
+
321
+ expect(sentContent()).toBe('Subagent "w" (sa-ptr-5) cancelled.');
322
+ });
323
+
324
+ it("gc-failed(closed + closedReason=gc + error 有值)→ 不追加指针行", () => {
325
+ notifier.notify({
326
+ id: "sa-ptr-6", status: "closed", closedReason: "gc", agent: "w",
327
+ error: "spawn EPIPE",
328
+ sessionFile: "/tmp/sessions/child-6.jsonl",
329
+ startedAt: 1, endedAt: 2,
330
+ });
331
+
332
+ expect(sentContent()).toBe('Subagent "w" (sa-ptr-6) failed: spawn EPIPE');
333
+ });
334
+
335
+ it("[review 修复] gc-failed + patchFile 并存 → failed 文案优先(失败轮也会写 patchFile,patch 提示不可达)", () => {
336
+ // 回归锚定:doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch,gc 失败 +
337
+ // worktree 并存时 patchFile 有值。判定顺序必须与 deriveClosedDisplay / renderRecordLines
338
+ // 同构(cancelled → gc+error → patch/result),否则 LLM 被告知 completed 掩盖失败。
339
+ notifier.notify({
340
+ id: "sa-ptr-8", status: "closed", closedReason: "gc", agent: "w",
341
+ error: "spawn EPIPE",
342
+ patchFile: "/tmp/patches/sa-ptr-8.patch",
343
+ sessionFile: "/tmp/sessions/child-8.jsonl",
344
+ startedAt: 1, endedAt: 2,
345
+ });
346
+
347
+ expect(sentContent()).toBe('Subagent "w" (sa-ptr-8) failed: spawn EPIPE');
348
+ });
349
+
350
+ it("one-shot(sessionFile 未透传 → undefined)→ closed 通知与改造前逐字节一致(基线常量锚定)", () => {
351
+ notifier.notify({
352
+ id: "sa-ptr-7", status: "closed", agent: "worker", result: "done",
353
+ startedAt: 1, endedAt: 2,
354
+ });
355
+
356
+ // 改造前形态:sessionFile undefined 时指针为空串,追加不改变输出——逐字节锁定
357
+ expect(sentContent()).toBe('Subagent "worker" (sa-ptr-7) completed. Result:\ndone');
358
+ });
359
+
360
+ it("[C-2] closed + totalRounds(chatMode close 终态通知)→ 文案附轮次统计 after N rounds", () => {
361
+ notifier.notify({
362
+ id: "sa-rounds-1", status: "closed", agent: "w", totalRounds: 3, result: "",
363
+ sessionFile: "/tmp/sessions/child-rounds.jsonl",
364
+ startedAt: 1, endedAt: 2,
365
+ });
366
+
367
+ // 设计 D2 路径①:closed 分支文案附轮次统计 + sessionFile 提示;
368
+ // 路径②正文空串(idle close)形态也走本分支(result 空串非 nullish,不触发 (empty))
369
+ expect(sentContent()).toBe(
370
+ 'Subagent "w" (sa-rounds-1) completed after 3 rounds. Result:\n' +
371
+ "\n\nFull transcript: /tmp/sessions/child-rounds.jsonl",
372
+ );
373
+ });
374
+
375
+ it("[C-2] 对照:totalRounds 缺失(one-shot 完成通知)→ 无轮次统计,文案逐字节保持 completed. Result:", () => {
376
+ notifier.notify({
377
+ id: "sa-rounds-2", status: "closed", agent: "w", result: "done",
378
+ sessionFile: "/tmp/sessions/child-oneshot.jsonl",
379
+ startedAt: 1, endedAt: 2,
380
+ });
381
+
382
+ // one-shot record 无轮次语义(totalRounds 不设置)——G4:文案不含统计
383
+ expect(sentContent()).toBe(
384
+ 'Subagent "w" (sa-rounds-2) completed. Result:\ndone' +
385
+ "\n\nFull transcript: /tmp/sessions/child-oneshot.jsonl",
386
+ );
387
+ });
388
+ });
@@ -0,0 +1,205 @@
1
+ // src/execution/__tests__/one-shot-upgrade.test.ts
2
+ //
3
+ // SP-5 one-shot upgrade:非 chatMode active record 收到 message 时自动升级 chatMode。
4
+ //
5
+ // 场景:one-shot subagent(conversation=false)完成首轮后 record 仍在内存(running/idle),
6
+ // LLM 调 message 续聊 → messageHandler 检测非 chatMode + active → 置 chatMode=true(upgrade)→
7
+ // 走 deliverMessage 统一路径(热路径或冷路径 resume)。
8
+ //
9
+ // mock 策略:真实 SubagentService + mock runSpawn(受控),getChildByRecord/spawnedChildren
10
+ // 提供真实 Map 语义(deliverMessage 热路径需要 child 存在)。
11
+
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+
18
+ // mock logger
19
+ const { loggerMock } = vi.hoisted(() => ({
20
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
21
+ }));
22
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
23
+
24
+ // mock session-runner:runSpawn 受控 + killAllSpawnedChildren 空实现;
25
+ // getChildByRecord/spawnedChildren 提供真实 Map 语义。
26
+ vi.mock("../session-runner.ts", () => {
27
+ const spawnedChildren = new Map<string, unknown>();
28
+ return {
29
+ runSpawn: vi.fn(),
30
+ killAllSpawnedChildren: vi.fn(),
31
+ spawnedChildren,
32
+ getChildByRecord: (id: string): unknown => spawnedChildren.get(id),
33
+ };
34
+ });
35
+
36
+ import { runSpawn, spawnedChildren } from "../session-runner.ts";
37
+ import type { SessionRunnerContext } from "../session-runner.ts";
38
+ import { createRecord } from "../execution-record.ts";
39
+ import { ModelConfigService } from "../model-config-service.ts";
40
+ import type { ModelInfo } from "../model-resolver.ts";
41
+ import { RecordStore } from "../record-store.ts";
42
+ import { SubagentService } from "../subagent-service.ts";
43
+ import type { PiLike } from "../subagent-service.ts";
44
+ import type { AgentResult, ExecutionRecord } from "../types.ts";
45
+ import { messageHandler } from "../../interface/subagent-actions.ts";
46
+
47
+ const mockRunSpawn = vi.mocked(runSpawn);
48
+
49
+ const STUB_MODEL: ModelInfo = { id: "test-model", name: "Test", provider: "test", reasoning: false };
50
+
51
+ function makeTmpAgentDir(): string {
52
+ return fs.mkdtempSync(path.join(os.tmpdir(), "upgrade-test-"));
53
+ }
54
+
55
+ function makePi(): PiLike & {
56
+ appendEntry: ReturnType<typeof vi.fn>;
57
+ events: { emit: ReturnType<typeof vi.fn> };
58
+ sendMessage: ReturnType<typeof vi.fn>;
59
+ } {
60
+ return { appendEntry: vi.fn(), events: { emit: vi.fn() }, sendMessage: vi.fn() };
61
+ }
62
+
63
+ function makeResult(success: boolean): AgentResult {
64
+ return {
65
+ text: success ? "done" : "err",
66
+ turns: 1,
67
+ durationMs: 100,
68
+ success,
69
+ error: success ? undefined : "boom",
70
+ sessionId: "sess-1",
71
+ toolCalls: [],
72
+ };
73
+ }
74
+
75
+ interface ServiceInternals {
76
+ store: RecordStore;
77
+ sessionRootId: string | null;
78
+ }
79
+
80
+ /** 非 chatMode background record(one-shot 模式)。 */
81
+ function makeOneShotRecord(
82
+ sessionRootId: string,
83
+ status: "running" = "running",
84
+ id = "sa-oneshot",
85
+ ): ExecutionRecord {
86
+ const record = createRecord(id, {
87
+ agent: "general-purpose",
88
+ model: "test/test-model",
89
+ thinkingLevel: "low",
90
+ mode: "background",
91
+ task: "one-shot task",
92
+ slug: "oneshot",
93
+ startedAt: 1000,
94
+ rootSessionId: sessionRootId,
95
+ // chatMode 不传 = undefined = 非 chatMode(one-shot)
96
+ });
97
+ record.status = status;
98
+ record.controller = new AbortController();
99
+ // v4 B-1:one-shot 完成后 record 为 running(idle 折入 running)+ isResumable(无活进程)。
100
+ // sessionFile 总设(resumeRound 冷路径校验需要,热路径无害)。
101
+ record.sessionFile = "/tmp/fake-session.jsonl";
102
+ record.round = 1;
103
+ return record;
104
+ }
105
+
106
+ // ============================================================
107
+ // SP-5 one-shot upgrade
108
+ // ============================================================
109
+
110
+ describe("SP-5 one-shot upgrade(message → chatMode + 冷 resume)", () => {
111
+ let agentDir: string;
112
+ let service: SubagentService;
113
+ let store: RecordStore;
114
+ let sessionRootId: string;
115
+
116
+ beforeEach(() => {
117
+ agentDir = makeTmpAgentDir();
118
+ const modelService = new ModelConfigService({ agentDir });
119
+ service = new SubagentService({ cwd: agentDir, modelService });
120
+ service.initSession({ pi: makePi(), sessionId: "root-session" });
121
+ const internals = service as unknown as ServiceInternals;
122
+ store = internals.store;
123
+ sessionRootId = internals.sessionRootId!;
124
+ });
125
+
126
+ afterEach(() => {
127
+ service.dispose();
128
+ fs.rmSync(agentDir, { recursive: true, force: true });
129
+ });
130
+
131
+ // TC-1: one-shot done 后 message 触发 upgrade(chatMode=true)
132
+ // 场景:one-shot running record → message → chatMode 被置 true → deliverMessage 热路径(child 存在)
133
+ it("TC-1: one-shot running record 收到 message → chatMode 升级为 true", async () => {
134
+ const record = makeOneShotRecord(sessionRootId, "running");
135
+ store.register(record);
136
+
137
+ // 注册 mock child 让 deliverMessage 走热路径
138
+ const mockChild = { killed: false, pid: 12345, stdin: { write: vi.fn(() => true) } };
139
+ spawnedChildren.set(record.id, mockChild as unknown);
140
+
141
+ // 验证升级前 chatMode 为 falsy
142
+ expect(record.chatMode).toBeFalsy();
143
+
144
+ await messageHandler(service, { subagentId: record.id, text: "follow-up" });
145
+
146
+ // 验证 chatMode 被升级
147
+ expect(record.chatMode).toBe(true);
148
+ // 验证 record 仍为 running(deliverMessage 热路径设 running)
149
+ expect(record.status).toBe("running");
150
+ // 验证 record 在内存中(非终态,未被 archive)
151
+ expect(store.getMutable(record.id)).toBeDefined();
152
+
153
+ // 清理
154
+ spawnedChildren.delete(record.id);
155
+ });
156
+
157
+ // TC-2: upgrade 后走冷路径 resume
158
+ // 场景:one-shot idle record(进程已死)→ message → chatMode=true → deliverMessage 冷路径 → resumeRound
159
+ it("TC-2: one-shot idle record 收到 message → chatMode 升级 + 冷路径 resume", async () => {
160
+ // one-shot 完成态 record(running + isResumable)需要 sessionFile(resumeRound 冷路径校验)
161
+ fs.writeFileSync("/tmp/fake-session.jsonl", "");
162
+ const record = makeOneShotRecord(sessionRootId);
163
+ store.register(record);
164
+
165
+ // mock runSpawn 返回成功(resume 后的 spawn)
166
+ mockRunSpawn.mockImplementation(async (_ctx: SessionRunnerContext) => {
167
+ return makeResult(true);
168
+ });
169
+
170
+ expect(record.chatMode).toBeFalsy();
171
+
172
+ await messageHandler(service, { subagentId: record.id, text: "resume message" });
173
+
174
+ // 验证 chatMode 被升级
175
+ expect(record.chatMode).toBe(true);
176
+ // 验证走了 resumeRound → runSpawn(冷路径)
177
+ expect(mockRunSpawn).toHaveBeenCalled();
178
+ // record 被 resumeRound 设为 running,runAndFinalize 完成后保持 running(v4 B-1 idle 折入 running)
179
+ // 关键是 record 不在终态
180
+ expect(record.status).not.toBe("closed");
181
+ });
182
+
183
+ // TC-3: upgrade 后 record 可续聊(非终态)
184
+ // 场景:验证升级后的 record 不会被 finalize 为终态,仍可接受后续 message
185
+ it("TC-3: upgrade 后 record 可续聊(非终态,仍在内存)", async () => {
186
+ const record = makeOneShotRecord(sessionRootId);
187
+ store.register(record);
188
+
189
+ // runSpawn 返回成功
190
+ mockRunSpawn.mockImplementation(async () => makeResult(true));
191
+
192
+ await messageHandler(service, { subagentId: record.id, text: "first resume" });
193
+
194
+ // chatMode 已升级
195
+ expect(record.chatMode).toBe(true);
196
+
197
+ // 关键验证:record 仍在内存(getMutable 返回非 undefined)——未被 archive 到终态
198
+ const mutable = store.getMutable(record.id);
199
+ expect(mutable).toBeDefined();
200
+ expect(mutable!.chatMode).toBe(true);
201
+
202
+ // 验证 status 不是终态(closed 是终态;running 是非终态,v4 B-1 idle 折入 running)
203
+ expect(record.status).toBe("running");
204
+ });
205
+ });