@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
@@ -0,0 +1,598 @@
1
+ // src/execution/__tests__/subagent-service-message-close.test.ts
2
+ //
3
+ // M2-B3 service 层:getRecordForAction 归属守卫 + closeSubagent 行为分流 + closeChatIdle。
4
+ //
5
+ // 沿用 run-and-finalize-chatmode.test.ts 的 mock 模式(真实 SubagentService + mock runSpawn +
6
+ // ServiceInternals cast 暴露 store)。验证 M2-B3 新增的归属守卫与 close 行为分流。
7
+
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import type { ChildProcess } from "node:child_process";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ // mock logger(doFinalizeRecord manifest 写入降级路径用)
16
+ const { loggerMock } = vi.hoisted(() => ({
17
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
18
+ }));
19
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
20
+
21
+ // mock session-runner(import 链需要 runSpawn/killAllSpawnedChildren/getChildByRecord 存在)
22
+ vi.mock("../session-runner.ts", () => ({
23
+ runSpawn: vi.fn(),
24
+ killAllSpawnedChildren: vi.fn(),
25
+ getChildByRecord: vi.fn(() => undefined),
26
+ }));
27
+
28
+ import { createRecord, updateFromEvent } from "../execution-record.ts";
29
+ import { ModelConfigService } from "../model-config-service.ts";
30
+ import { RecordStore } from "../record-store.ts";
31
+ import { SubagentService } from "../subagent-service.ts";
32
+ import type { PiLike } from "../subagent-service.ts";
33
+ import type { ExecutionRecord } from "../types.ts";
34
+ import { getChildByRecord } from "../session-runner.ts";
35
+ import {
36
+ armIdleTimer,
37
+ disarmIdleTimer,
38
+ hasIdleTimer,
39
+ _resetLifecycleState,
40
+ } from "../lifecycle-manager.ts";
41
+
42
+ function makeTmpAgentDir(): string {
43
+ return fs.mkdtempSync(path.join(os.tmpdir(), "msg-close-svc-"));
44
+ }
45
+
46
+ function makePi(): PiLike & {
47
+ appendEntry: ReturnType<typeof vi.fn>;
48
+ events: { emit: ReturnType<typeof vi.fn> };
49
+ sendMessage: ReturnType<typeof vi.fn>;
50
+ } {
51
+ return { appendEntry: vi.fn(), events: { emit: vi.fn() }, sendMessage: vi.fn() } as unknown as PiLike & {
52
+ appendEntry: ReturnType<typeof vi.fn>;
53
+ events: { emit: ReturnType<typeof vi.fn> };
54
+ sendMessage: ReturnType<typeof vi.fn>;
55
+ };
56
+ }
57
+
58
+ /** 构造 chatMode background record(带 controller,模拟真实 background record)。 */
59
+ function makeRecord(overrides: Partial<ExecutionRecord> & { id?: string } = {}): ExecutionRecord {
60
+ const { id = "sa-test", ...rest } = overrides;
61
+ const r = createRecord(id, {
62
+ agent: "general-purpose",
63
+ model: "test/model",
64
+ mode: "background",
65
+ task: "test",
66
+ slug: "test",
67
+ startedAt: 1000,
68
+ rootSessionId: "root-session",
69
+ chatMode: true,
70
+ controller: new AbortController(),
71
+ });
72
+ Object.assign(r, rest);
73
+ return r;
74
+ }
75
+
76
+ interface ServiceInternals {
77
+ store: RecordStore;
78
+ sessionRootId: string | null;
79
+ }
80
+
81
+ type MockPi = ReturnType<typeof makePi>;
82
+
83
+ function setup(): {
84
+ agentDir: string;
85
+ service: SubagentService;
86
+ store: RecordStore;
87
+ sessionRootId: string;
88
+ pi: MockPi;
89
+ } {
90
+ const agentDir = makeTmpAgentDir();
91
+ const modelService = new ModelConfigService({ agentDir });
92
+ const service = new SubagentService({ cwd: agentDir, modelService });
93
+ // [C2] pi 提为外层引用(对照 real-chain :139-140 形态)——close 现状语义断言需读
94
+ // pi.sendMessage.mock.calls(末条通知 content 指针行),内联构造不保留引用。
95
+ const pi = makePi();
96
+ service.initSession({ pi, sessionId: "root-session" });
97
+ const internals = service as unknown as ServiceInternals;
98
+ return { agentDir, service, store: internals.store, sessionRootId: internals.sessionRootId!, pi };
99
+ }
100
+
101
+ // ============================================================
102
+ // getRecordForAction 归属守卫(决策 3)
103
+ // ============================================================
104
+
105
+ describe("getRecordForAction 归属守卫(决策 3)", () => {
106
+ let agentDir: string;
107
+ let service: SubagentService;
108
+ let store: RecordStore;
109
+ let sessionRootId: string;
110
+
111
+ beforeEach(() => {
112
+ ({ agentDir, service, store, sessionRootId } = setup());
113
+ });
114
+
115
+ afterEach(() => {
116
+ service.dispose();
117
+ fs.rmSync(agentDir, { recursive: true, force: true });
118
+ });
119
+
120
+ it("正常(rootSessionId 匹配)→ 返回可变 record", () => {
121
+ const record = makeRecord({ rootSessionId: sessionRootId });
122
+ store.register(record);
123
+ expect(service.getRecordForAction(record.id)).toBe(record);
124
+ });
125
+
126
+ it("不存在 → throw not found or not owned", () => {
127
+ expect(() => service.getRecordForAction("sa-nonexistent")).toThrow(/not found or not owned/);
128
+ });
129
+
130
+ it("rootSessionId 不匹配 → throw not found or not owned(不区分 not found vs not owned,防信息泄露)", () => {
131
+ const record = makeRecord({ id: "sa-other", rootSessionId: "other-session" });
132
+ store.register(record);
133
+ expect(() => service.getRecordForAction("sa-other")).toThrow(/not found or not owned/);
134
+ });
135
+ });
136
+
137
+ // ============================================================
138
+ // closeSubagent 行为分流
139
+ // ============================================================
140
+
141
+ describe("closeSubagent 行为分流", () => {
142
+ let agentDir: string;
143
+ let service: SubagentService;
144
+ let store: RecordStore;
145
+ let pi: MockPi;
146
+
147
+ beforeEach(() => {
148
+ ({ agentDir, service, store, pi } = setup());
149
+ });
150
+
151
+ afterEach(() => {
152
+ service.dispose();
153
+ fs.rmSync(agentDir, { recursive: true, force: true });
154
+ });
155
+
156
+ it("running + force:false + 有活进程 → 置 closeAfterRound=true(不立即终态,等轮完)", async () => {
157
+ // v4 B-1:closeSubagent running 分支按 isResumable 区分——有活进程(isResumable=false)
158
+ // 走 closeAfterRound;无活进程(isResumable=true)走 closeChatIdle。本用例 mock 活进程。
159
+ vi.mocked(getChildByRecord).mockReturnValueOnce({ killed: false } as never);
160
+ const record = makeRecord({ status: "running" });
161
+ store.register(record);
162
+
163
+ await service.closeSubagent(record, false);
164
+
165
+ expect(record.closeAfterRound).toBe(true);
166
+ expect(record.status).toBe("running"); // 仍 running
167
+ expect(store.getMutable(record.id)).toBe(record); // 留内存
168
+ });
169
+
170
+ it("running + 无活进程(旧 idle)→ closeChatIdle(closed 终态化 + archive)", async () => {
171
+ // v4 B-1:旧 idle 折入 running。running + force:false + isResumable=true(getChildByRecord
172
+ // 默认 mock 返回 undefined = 无活进程)→ closeChatIdle 立即终态化。
173
+ const record = makeRecord({ status: "running", round: 1 });
174
+ record.sessionFile = path.join(agentDir, "test.jsonl");
175
+ store.register(record);
176
+
177
+ await service.closeSubagent(record, false);
178
+
179
+ expect(record.status).toBe("closed");
180
+ expect(store.getMutable(record.id)).toBeUndefined(); // archived
181
+ });
182
+
183
+ it("running + force:true → cancelBackground(closed+cancelled 终态化 + archive)", async () => {
184
+ const record = makeRecord({ status: "running", sessionFile: path.join(agentDir, "run.jsonl") });
185
+ store.register(record);
186
+
187
+ await service.closeSubagent(record, true);
188
+
189
+ // v4 B-1:cancelled 折入 closed(closedReason='cancelled' 区分用户取消语义)
190
+ expect(record.status).toBe("closed");
191
+ expect(record.closedReason).toBe("cancelled");
192
+ expect(store.getMutable(record.id)).toBeUndefined(); // archived
193
+ });
194
+
195
+ it("终态(done)→ 幂等 no-op(不改状态、不 archive)", async () => {
196
+ const record = makeRecord({ status: "closed" });
197
+ store.register(record);
198
+
199
+ await service.closeSubagent(record, false);
200
+
201
+ expect(record.status).toBe("closed"); // 不变
202
+ });
203
+
204
+ // ── [M5] Path A(isIdle timer armed + 保活进程)立即终态化 ─────────────
205
+
206
+ it("[M5] Path A(timer armed + 活进程)+ force:false → 立即终态化(closed+user-close + kill 进程 + disarm timer),不置 closeAfterRound", async () => {
207
+ // 旧代码 Path A 走 else 置 closeAfterRound,但 chatMode 轮完成恒经 agent_settled →
208
+ // onRoundSettled → runAndFinalize early return(isIdle 恒 true),标志无人消费,
209
+ // tool 却返回 {closed:true}(谎报)。
210
+ const killFn = vi.fn(() => true);
211
+ const child = { killed: false, kill: killFn } as unknown as ChildProcess;
212
+ vi.mocked(getChildByRecord).mockReturnValueOnce(child);
213
+ const record = makeRecord({ status: "running", round: 1 });
214
+ record.sessionFile = path.join(agentDir, "path-a.jsonl");
215
+ store.register(record);
216
+ armIdleTimer(record.id, () => {}); // Path A:轮次完成、进程保活等待续聊
217
+ expect(hasIdleTimer(record.id)).toBe(true);
218
+
219
+ try {
220
+ await service.closeSubagent(record, false);
221
+ } finally {
222
+ disarmIdleTimer(record.id); // 断言失败路径的兜底清理
223
+ }
224
+
225
+ expect(record.closeAfterRound).toBeUndefined(); // 不置标志
226
+ expect(record.status).toBe("closed");
227
+ expect(record.closedReason).toBe("user-close");
228
+ expect(store.getMutable(record.id)).toBeUndefined(); // archived
229
+ expect(killFn).toHaveBeenCalledWith("SIGTERM"); // 保活进程回收
230
+ expect(hasIdleTimer(record.id)).toBe(false); // timer disarmed
231
+ });
232
+
233
+ it("[M5] Path B(无活进程、timer 未 armed)+ force:false → 立即终态化(同旧 isResumable 行为)+ 终态通知", async () => {
234
+ const record = makeRecord({ status: "running", round: 1 });
235
+ record.sessionFile = path.join(agentDir, "path-b.jsonl");
236
+ store.register(record);
237
+
238
+ await service.closeSubagent(record, false);
239
+
240
+ expect(record.status).toBe("closed");
241
+ expect(record.closedReason).toBe("user-close");
242
+ expect(store.getMutable(record.id)).toBeUndefined();
243
+ // [C-1] close 终态通知(设计 D2 路径②):chatMode close 后父 agent 收到终态通知——
244
+ // 正文空串(doneResult.text 恒空)+ 轮次统计 + Full transcript 指针行
245
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
246
+ const sent = pi.sendMessage.mock.calls[0]![0] as { content: string };
247
+ expect(sent.content).toContain("completed after 1 round.");
248
+ expect(sent.content).toContain(`\n\nFull transcript: ${record.sessionFile}`);
249
+ });
250
+
251
+ // ── [M6] cancelBackground 显式 kill(chatMode 热路径轮中 cancel)────────
252
+
253
+ it("[M6] running + force:true + 活进程(热路径轮中)→ cancelBackground 显式 SIGTERM + disarm idle timer", async () => {
254
+ // chatMode 首轮 agent_settled 后 runSpawn 提前 resolveRun(0) 返回,
255
+ // abort→kill listener 已被 removeEventListener——热路径轮中 cancel 只能靠显式 kill。
256
+ const killFn = vi.fn(() => true);
257
+ const child = { killed: false, kill: killFn } as unknown as ChildProcess;
258
+ vi.mocked(getChildByRecord).mockReturnValueOnce(child);
259
+ const record = makeRecord({ status: "running", sessionFile: path.join(agentDir, "hot.jsonl") });
260
+ record.controller = new AbortController();
261
+ store.register(record);
262
+ armIdleTimer(record.id, () => {}); // Path A 等待续聊态(cancel 时必须 disarm)
263
+ expect(hasIdleTimer(record.id)).toBe(true);
264
+
265
+ try {
266
+ await service.closeSubagent(record, true);
267
+ } finally {
268
+ disarmIdleTimer(record.id); // 断言失败路径的兜底清理
269
+ }
270
+
271
+ expect(killFn).toHaveBeenCalledWith("SIGTERM"); // 子进程收到 kill
272
+ expect(record.status).toBe("closed");
273
+ expect(record.closedReason).toBe("cancelled");
274
+ expect(hasIdleTimer(record.id)).toBe(false); // timer disarmed
275
+ expect(store.getMutable(record.id)).toBeUndefined(); // archived
276
+ });
277
+ });
278
+
279
+ // ============================================================
280
+ // [M5] closeAfterRound 消费点:onRoundSettled(chatMode 热路径轮完成)
281
+ // ============================================================
282
+
283
+ describe("[M5] closeAfterRound 消费点挂 onRoundSettled(chatMode 轮完成终态化)", () => {
284
+ let agentDir: string;
285
+ let service: SubagentService;
286
+ let store: RecordStore;
287
+
288
+ beforeEach(() => {
289
+ vi.mocked(getChildByRecord).mockReset();
290
+ vi.mocked(getChildByRecord).mockReturnValue(undefined);
291
+ ({ agentDir, service, store } = setup());
292
+ });
293
+
294
+ afterEach(() => {
295
+ service.dispose();
296
+ _resetLifecycleState();
297
+ fs.rmSync(agentDir, { recursive: true, force: true });
298
+ });
299
+
300
+ it("热路径轮完成(onRoundSettled)+ closeAfterRound=true → 消费标志终态化(closed+user-close + archive)", async () => {
301
+ // 热路径轮不经 runAndFinalize(deliverMessage 直接 sendPromptCommand),旧消费点
302
+ // (runAndFinalize CAS 分支)不可达——唯一可达的收尾点是 onRoundSettled。
303
+ const killFn = vi.fn(() => true);
304
+ const child = { killed: false, kill: killFn } as unknown as ChildProcess;
305
+ // mockReturnValue(非 Once):notifyComplete → hasRunningBackground → hasLiveProcessHandle
306
+ // 也会读 getChildByRecord,Once mock 会被提前消费掉。
307
+ vi.mocked(getChildByRecord).mockReturnValue(child);
308
+ const internals = service as unknown as { buildSessionRunnerContext(): { onRoundSettled?: (r: ExecutionRecord) => void } };
309
+ const ctx = internals.buildSessionRunnerContext();
310
+ expect(typeof ctx.onRoundSettled).toBe("function");
311
+
312
+ const record = makeRecord({ id: "sa-m5", status: "running" });
313
+ record.sessionFile = path.join(agentDir, "m5.jsonl");
314
+ store.register(record);
315
+ record.closeAfterRound = true; // busy 轮中 close(force:false) 置的标志
316
+ armIdleTimer(record.id, () => {}); // session-runner 在 onRoundSettled 前已 arm(时序还原)
317
+ try {
318
+ ctx.onRoundSettled!(record);
319
+ // closeAfterRoundSettled 是 async(finalizeRecord 内 manifest 写入)——等微任务收尾完成
320
+ await vi.waitFor(() => expect(store.getMutable(record.id)).toBeUndefined());
321
+ } finally {
322
+ disarmIdleTimer(record.id);
323
+ }
324
+
325
+ expect(record.closeAfterRound).toBeUndefined(); // 标志已消费
326
+ expect(record.status).toBe("closed");
327
+ expect(record.closedReason).toBe("user-close");
328
+ expect(killFn).toHaveBeenCalledWith("SIGTERM"); // 保活进程回收
329
+ expect(hasIdleTimer(record.id)).toBe(false);
330
+ });
331
+
332
+ it("onRoundSettled 无 closeAfterRound → 不终态化(record 保持 running-resumable,轮完成通知照常)", async () => {
333
+ const internals = service as unknown as { buildSessionRunnerContext(): { onRoundSettled?: (r: ExecutionRecord) => void } };
334
+ const ctx = internals.buildSessionRunnerContext();
335
+ const record = makeRecord({ id: "sa-m5-keep", status: "running" });
336
+ store.register(record);
337
+ armIdleTimer(record.id, () => {});
338
+ try {
339
+ ctx.onRoundSettled!(record);
340
+ } finally {
341
+ disarmIdleTimer(record.id);
342
+ }
343
+
344
+ expect(record.status).toBe("running"); // 不终态化
345
+ expect(record.round).toBe(1); // round 累加照常
346
+ expect(store.getMutable(record.id)).toBe(record); // 留内存
347
+ });
348
+ });
349
+
350
+ // ============================================================
351
+ // [C2 wave2] close 现状语义 + sessionFile 条件透传
352
+ // ============================================================
353
+
354
+ describe("[C2] close 现状语义 + sessionFile 条件透传", () => {
355
+ let agentDir: string;
356
+ let service: SubagentService;
357
+ let store: RecordStore;
358
+ let pi: MockPi;
359
+
360
+ beforeEach(() => {
361
+ vi.mocked(getChildByRecord).mockReset();
362
+ // 默认无活进程(Path B / isResumable 形态);hasRunningBackground 亦读此 mock
363
+ vi.mocked(getChildByRecord).mockReturnValue(undefined);
364
+ ({ agentDir, service, store, pi } = setup());
365
+ });
366
+
367
+ afterEach(() => {
368
+ service.dispose();
369
+ _resetLifecycleState();
370
+ fs.rmSync(agentDir, { recursive: true, force: true });
371
+ });
372
+
373
+ it("close 语义:轮次通知(含指针行)发出后 closeAfterRoundSettled 终态化 + 终态通知,两条都送达", async () => {
374
+ // [C-1] 修复后语义:终态通知经 notifyClosed 显式发送(dedup 身份 = 裸 id,与轮次通知
375
+ // 的 id:round key 区分)——close 后父 agent 收到「最后一轮轮次通知 + 终态通知」两条。
376
+ const internals = service as unknown as { buildSessionRunnerContext(): { onRoundSettled?: (r: ExecutionRecord) => void } };
377
+ const ctx = internals.buildSessionRunnerContext();
378
+ const record = makeRecord({ id: "sa-c2-close", status: "running" });
379
+ record.sessionFile = path.join(agentDir, "c2-close.jsonl");
380
+ store.register(record);
381
+ record.closeAfterRound = true; // busy 轮中 close(force:false) 置的标志
382
+ armIdleTimer(record.id, () => {});
383
+ try {
384
+ ctx.onRoundSettled!(record);
385
+ // closeAfterRoundSettled 是 async(finalizeRecord 链)——终态通知在链末尾的
386
+ // notifyClosed,等它发出而不是只等 archive(archive 在链中段,先于通知)
387
+ await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(2));
388
+ await vi.waitFor(() => expect(store.getMutable(record.id)).toBeUndefined());
389
+ } finally {
390
+ disarmIdleTimer(record.id);
391
+ }
392
+
393
+ // 轮次通知 1 条 + 终态通知 1 条(dedup 不吞)
394
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
395
+ // 第 1 条:轮次通知含 Full transcript 指针行(chatMode:true + sessionFile 有值 → 透传)
396
+ const roundMsg = pi.sendMessage.mock.calls[0]![0] as { content: string };
397
+ expect(roundMsg.content).toContain("finished a round");
398
+ expect(roundMsg.content).toContain(`\n\nFull transcript: ${record.sessionFile}`);
399
+ // 第 2 条:终态通知含轮次统计 + 指针行
400
+ const closeMsg = pi.sendMessage.mock.calls[1]![0] as { content: string };
401
+ expect(closeMsg.content).toContain("completed after 1 round.");
402
+ expect(closeMsg.content).toContain(`\n\nFull transcript: ${record.sessionFile}`);
403
+ });
404
+
405
+ it("one-shot 条件透传(R4 必选):chatMode:false + sessionFile 有值 → 通知不透传 sessionFile、content 无指针行", () => {
406
+ // 锁死 toNotifyRecord 的 chatMode 条件(C2C2 契约)——漏加条件时 notifier 单测不红
407
+ //(notifier 层只见最终字段),此用例走真实 SubagentService + toNotifyRecord 路径必红。
408
+ // 同时服务 C2TC6 结构级断言。
409
+ const record = makeRecord({ id: "sa-c2-oneshot", status: "running", chatMode: false, result: "done" });
410
+ record.sessionFile = path.join(agentDir, "c2-oneshot.jsonl");
411
+ store.register(record);
412
+
413
+ // running + 无活进程(getChildByRecord mock undefined)→ isResumable 放行 →
414
+ // 非 chatMode 映射 closed 通知,立即 flush(无其他 running background)
415
+ const svc = service as unknown as { notifyComplete(r: ExecutionRecord): void };
416
+ svc.notifyComplete(record);
417
+
418
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
419
+ const msg = pi.sendMessage.mock.calls[0]![0] as {
420
+ content: string;
421
+ details?: { sessionFile?: string };
422
+ };
423
+ // 结构级:one-shot 通知的 BgNotifyRecord 不含 sessionFile
424
+ expect(msg.details?.sessionFile).toBeUndefined();
425
+ // 语义级:正文无指针行,且与改造前逐字节一致(G4)
426
+ expect(msg.content).not.toContain("Full transcript");
427
+ expect(msg.content).toBe('Subagent "general-purpose" (sa-c2-oneshot) completed. Result:\ndone');
428
+ });
429
+
430
+ it("chatMode:true 对照透传:isResumable 放行的轮次通知携带 sessionFile + 指针行(漏加 chatMode 条件时本用例红)", () => {
431
+ const record = makeRecord({ id: "sa-c2-chat", status: "running", result: "round reply" });
432
+ record.sessionFile = path.join(agentDir, "c2-chat.jsonl");
433
+ store.register(record);
434
+
435
+ const svc = service as unknown as { notifyComplete(r: ExecutionRecord): void };
436
+ svc.notifyComplete(record);
437
+
438
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
439
+ const msg = pi.sendMessage.mock.calls[0]![0] as {
440
+ content: string;
441
+ details?: { sessionFile?: string };
442
+ };
443
+ expect(msg.details?.sessionFile).toBe(path.join(agentDir, "c2-chat.jsonl"));
444
+ expect(msg.content).toContain(`\n\nFull transcript: ${path.join(agentDir, "c2-chat.jsonl")}`);
445
+ });
446
+ });
447
+
448
+ // ============================================================
449
+ // [C-4] chatMode 轮次增量(onRoundSettled 写点)+ close 终态通知
450
+ // ============================================================
451
+
452
+ describe("[C-4] onRoundSettled 轮次增量 + [C-1] close 终态通知", () => {
453
+ let agentDir: string;
454
+ let service: SubagentService;
455
+ let store: RecordStore;
456
+ let pi: MockPi;
457
+
458
+ beforeEach(() => {
459
+ vi.mocked(getChildByRecord).mockReset();
460
+ // 默认无活进程:hasRunningBackground=false → notify 立即 flush,sendMessage 同步可断言
461
+ vi.mocked(getChildByRecord).mockReturnValue(undefined);
462
+ ({ agentDir, service, store, pi } = setup());
463
+ });
464
+
465
+ afterEach(() => {
466
+ service.dispose();
467
+ _resetLifecycleState();
468
+ fs.rmSync(agentDir, { recursive: true, force: true });
469
+ });
470
+
471
+ /** 真实回调链(与生产一致:agent_settled → onRoundSettled),turns 经 updateFromEvent 累积。 */
472
+ function settleRound(record: ExecutionRecord): void {
473
+ (service as unknown as {
474
+ buildSessionRunnerContext(): { onRoundSettled?: (r: ExecutionRecord) => void };
475
+ }).buildSessionRunnerContext().onRoundSettled!(record);
476
+ }
477
+
478
+ it("空增量轮:通知正文占位 (no output this round),不含上一轮文本(D5,onRoundSettled 写点)", () => {
479
+ const record = makeRecord({ id: "sa-empty-round", status: "running" });
480
+ record.sessionFile = path.join(agentDir, "empty-round.jsonl");
481
+ store.register(record);
482
+ armIdleTimer(record.id, () => {});
483
+ try {
484
+ // 第 1 轮:有真实回复
485
+ updateFromEvent(record, { type: "text_delta", delta: "ROUND-ONE-REPLY" });
486
+ updateFromEvent(record, { type: "turn_end" });
487
+ settleRound(record);
488
+
489
+ // 第 2 轮:零新事件(纯空转/工具轮形态)→ 空增量
490
+ settleRound(record);
491
+ } finally {
492
+ disarmIdleTimer(record.id);
493
+ }
494
+
495
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
496
+ const round1 = pi.sendMessage.mock.calls[0]![0] as { content: string };
497
+ expect(round1.content).toContain("ROUND-ONE-REPLY");
498
+ // D5 核心:空增量轮占位,不沿用旧 result(= 上一轮增量 → 父 agent 误读为原样重复回复)
499
+ const round2 = pi.sendMessage.mock.calls[1]![0] as { content: string };
500
+ expect(round2.content).toContain("(no output this round)");
501
+ expect(round2.content).not.toContain("ROUND-ONE-REPLY");
502
+ });
503
+
504
+ it("多轮增量:第 2 条通知只含第 2 轮文本、不含第 1 轮(D1/G1,roundBaseTurnIndex 推进)", () => {
505
+ const record = makeRecord({ id: "sa-multi-round", status: "running" });
506
+ record.sessionFile = path.join(agentDir, "multi-round.jsonl");
507
+ store.register(record);
508
+ armIdleTimer(record.id, () => {});
509
+ try {
510
+ // 第 1 轮
511
+ updateFromEvent(record, { type: "text_delta", delta: "ALPHA-ROUND-ONE" });
512
+ updateFromEvent(record, { type: "turn_end" });
513
+ settleRound(record);
514
+
515
+ // 第 2 轮:turn_end 已闭合前轮 turn → text_delta 开新 turn
516
+ updateFromEvent(record, { type: "text_delta", delta: "BETA-ROUND-TWO" });
517
+ updateFromEvent(record, { type: "turn_end" });
518
+ settleRound(record);
519
+ } finally {
520
+ disarmIdleTimer(record.id);
521
+ }
522
+
523
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
524
+ const round1 = pi.sendMessage.mock.calls[0]![0] as { content: string };
525
+ expect(round1.content).toContain("ALPHA-ROUND-ONE");
526
+ expect(round1.content).not.toContain("BETA-ROUND-TWO");
527
+ const round2 = pi.sendMessage.mock.calls[1]![0] as { content: string };
528
+ expect(round2.content).toContain("BETA-ROUND-TWO");
529
+ expect(round2.content).not.toContain("ALPHA-ROUND-ONE");
530
+ // base 推进写点:第 2 轮后边界 = 2 个 turn(第 3 轮增量将从新 turn 起)
531
+ expect(record.roundBaseTurnIndex).toBe(2);
532
+ });
533
+
534
+ it("[C-1] idle close(closeChatIdle)后父侧收到终态通知:正文空串 + Full transcript 指针行,轮次通知与终态通知都送达(dedup 不吞)", async () => {
535
+ const record = makeRecord({ id: "sa-idle-close", status: "running" });
536
+ record.sessionFile = path.join(agentDir, "idle-close.jsonl");
537
+ store.register(record);
538
+ armIdleTimer(record.id, () => {}); // Path A:轮次完成、进程保活等待续聊
539
+ try {
540
+ updateFromEvent(record, { type: "text_delta", delta: "LAST-ROUND-INCREMENT" });
541
+ updateFromEvent(record, { type: "turn_end" });
542
+ settleRound(record); // 第 1 条:轮次通知
543
+ await service.closeSubagent(record, false); // idle close(D2 路径②)
544
+ } finally {
545
+ disarmIdleTimer(record.id);
546
+ }
547
+
548
+ // dedup 不吞:轮次通知(key=id:1)+ 终态通知(key=裸 id)两条都送达
549
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
550
+ const roundMsg = pi.sendMessage.mock.calls[0]![0] as { content: string };
551
+ expect(roundMsg.content).toContain("finished a round");
552
+ expect(roundMsg.content).toContain("LAST-ROUND-INCREMENT");
553
+ // 终态通知:正文空串占位(不含上一轮增量,D2 路径②)+ 轮次统计 + 指针行
554
+ const closeMsg = pi.sendMessage.mock.calls[1]![0] as { content: string };
555
+ expect(closeMsg.content).toContain("completed after 1 round.");
556
+ expect(closeMsg.content).toContain(`\n\nFull transcript: ${record.sessionFile}`);
557
+ expect(closeMsg.content).not.toContain("LAST-ROUND-INCREMENT");
558
+ });
559
+
560
+ it("[C-1] 路径①(closeAfterRoundSettled):轮次通知与终态通知两条都送达,终态携带本轮增量 + 轮次统计 + 指针行", async () => {
561
+ const record = makeRecord({ id: "sa-path-one", status: "running" });
562
+ record.sessionFile = path.join(agentDir, "path-one.jsonl");
563
+ store.register(record);
564
+ record.closeAfterRound = true; // busy 轮中 close(force:false) 置的标志
565
+ armIdleTimer(record.id, () => {});
566
+ try {
567
+ updateFromEvent(record, { type: "text_delta", delta: "PATH-ONE-ROUND-REPLY" });
568
+ updateFromEvent(record, { type: "turn_end" });
569
+ settleRound(record); // 轮次通知 + 消费标志 → closeAfterRoundSettled 终态化
570
+ // 终态通知在 async finalize 链末尾的 notifyClosed——直接等它发出
571
+ await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(2));
572
+ await vi.waitFor(() => expect(store.getMutable(record.id)).toBeUndefined());
573
+ } finally {
574
+ disarmIdleTimer(record.id);
575
+ }
576
+
577
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
578
+ const roundMsg = pi.sendMessage.mock.calls[0]![0] as { content: string };
579
+ expect(roundMsg.content).toContain("finished a round");
580
+ expect(roundMsg.content).toContain("PATH-ONE-ROUND-REPLY");
581
+ // 路径①终态:合成 result 沿用 record.result(= 本轮增量,D2 路径①)+ 统计 + 指针
582
+ const closeMsg = pi.sendMessage.mock.calls[1]![0] as { content: string };
583
+ expect(closeMsg.content).toContain("completed after 1 round.");
584
+ expect(closeMsg.content).toContain("PATH-ONE-ROUND-REPLY");
585
+ expect(closeMsg.content).toContain(`\n\nFull transcript: ${record.sessionFile}`);
586
+ });
587
+
588
+ it("[G4] 对照:one-shot(chatMode:false)close → 不发终态通知(现状行为字节不变)", async () => {
589
+ const record = makeRecord({ id: "sa-oneshot-close", status: "running", chatMode: false });
590
+ record.sessionFile = path.join(agentDir, "oneshot-close.jsonl");
591
+ store.register(record);
592
+
593
+ await service.closeSubagent(record, false); // resumable → closeChatIdle → notifyClosed 拒绝
594
+
595
+ expect(record.status).toBe("closed");
596
+ expect(pi.sendMessage).not.toHaveBeenCalled();
597
+ });
598
+ });