@zhushanwen/pi-subagent-workflow 7.4.0 → 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 (148) hide show
  1. package/README.md +15 -0
  2. package/package.json +1 -1
  3. package/skills/workflow-script-format/SKILL.md +1 -1
  4. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  5. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  6. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  7. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  8. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  9. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  10. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  11. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  12. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  13. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  14. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  15. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  16. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  17. package/src/execution/__tests__/execution-record.test.ts +199 -0
  18. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  19. package/src/execution/__tests__/format.test.ts +131 -7
  20. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  21. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  22. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  23. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  24. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  25. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  26. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  27. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  28. package/src/execution/__tests__/list-component.test.ts +59 -5
  29. package/src/execution/__tests__/list-fields.test.ts +109 -0
  30. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  31. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  32. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  33. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  34. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  35. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  36. package/src/execution/__tests__/record-store.test.ts +158 -52
  37. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  38. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  39. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  40. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  41. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  42. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  43. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  44. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  45. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  46. package/src/execution/__tests__/session-pending.test.ts +61 -2
  47. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  48. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  49. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  50. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  51. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  52. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  53. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  54. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  55. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  56. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  57. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  58. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  59. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  60. package/src/execution/__tests__/tool-action.test.ts +12 -10
  61. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  62. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  63. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  64. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  65. package/src/execution/argv-mirror.ts +21 -2
  66. package/src/execution/execution-record.ts +126 -9
  67. package/src/execution/finalize-record.ts +90 -13
  68. package/src/execution/lifecycle-manager.ts +484 -0
  69. package/src/execution/lifecycle-predicates.ts +65 -0
  70. package/src/execution/manifest-store.ts +61 -16
  71. package/src/execution/model-resolver.ts +26 -5
  72. package/src/execution/notifier.ts +69 -12
  73. package/src/execution/pi-invocation.ts +21 -1
  74. package/src/execution/record-store.ts +554 -107
  75. package/src/execution/session-pending.ts +116 -45
  76. package/src/execution/session-reconstructor.ts +224 -7
  77. package/src/execution/session-runner.ts +270 -68
  78. package/src/execution/sessions-index.ts +304 -0
  79. package/src/execution/stdin-writer.ts +93 -7
  80. package/src/execution/stream-sink.ts +20 -3
  81. package/src/execution/subagent-service.ts +867 -138
  82. package/src/execution/turn-limiter.ts +14 -0
  83. package/src/execution/types.ts +200 -19
  84. package/src/execution/worktree-manager.ts +128 -49
  85. package/src/execution/worktree-registry.ts +13 -2
  86. package/src/index.ts +277 -19
  87. package/src/injectors/subagent-list-injector.ts +26 -8
  88. package/src/injectors/workflow-list-injector.ts +25 -8
  89. package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
  90. package/src/interface/__tests__/tool-render.test.ts +10 -8
  91. package/src/interface/bg-notify-render.ts +32 -8
  92. package/src/interface/command-actions.ts +26 -7
  93. package/src/interface/commands.ts +21 -22
  94. package/src/interface/format.ts +43 -16
  95. package/src/interface/gui-mappers.ts +6 -8
  96. package/src/interface/helpers.ts +170 -10
  97. package/src/interface/list-component.ts +53 -14
  98. package/src/interface/subagent-actions.ts +235 -17
  99. package/src/interface/subagent-tool.ts +81 -16
  100. package/src/interface/subagents.ts +2 -1
  101. package/src/interface/tool-render.ts +11 -24
  102. package/src/interface/tool-workflow.ts +20 -35
  103. package/src/interface/views/WorkflowsView.ts +89 -32
  104. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  105. package/src/interface/views/format.ts +3 -3
  106. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  107. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  108. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  109. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  110. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  111. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  112. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  113. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  114. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  115. package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
  116. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  117. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  118. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  119. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  120. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  121. package/src/orchestration/agent-opts-resolver.ts +4 -1
  122. package/src/orchestration/args-validator.ts +2 -2
  123. package/src/orchestration/config-loader.ts +30 -1
  124. package/src/orchestration/error-recovery.ts +133 -29
  125. package/src/orchestration/execute-agent-call.ts +31 -7
  126. package/src/orchestration/jsonl-run-store.ts +287 -40
  127. package/src/orchestration/launcher.ts +7 -1
  128. package/src/orchestration/lifecycle.ts +135 -132
  129. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  130. package/src/orchestration/models/budget.ts +1 -1
  131. package/src/orchestration/models/run-runtime.ts +15 -17
  132. package/src/orchestration/models/run-spec.ts +2 -2
  133. package/src/orchestration/models/run-state.ts +3 -3
  134. package/src/orchestration/models/trace.ts +95 -15
  135. package/src/orchestration/models/types.ts +8 -9
  136. package/src/orchestration/models/workflow-run.ts +50 -71
  137. package/src/orchestration/models/workflow-script.ts +32 -1
  138. package/src/orchestration/skill-discovery.ts +30 -0
  139. package/src/orchestration/worker-handle.ts +1 -1
  140. package/src/orchestration/worker-host.ts +1 -1
  141. package/src/orchestration/worker-script-builder.ts +29 -10
  142. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  143. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  144. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  145. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  146. package/src/shared/agent-ref.ts +16 -0
  147. package/src/shared/resource-discovery.ts +147 -58
  148. package/src/shared/schema-jsonify.ts +53 -0
@@ -40,7 +40,15 @@ vi.mock("node:child_process", async () => {
40
40
 
41
41
  return {
42
42
  spawn: vi.fn(() => new FakeChild()),
43
- execFileSync: vi.fn(() => ""),
43
+ // buildEnvBlock git branch 调用(execFile 异步):默认 err-first 兜底 → catch → branch=""
44
+ execFile: vi.fn(
45
+ (
46
+ _cmd: string,
47
+ _args: readonly string[],
48
+ _opts: unknown,
49
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
50
+ ) => cb(new Error("execFile not configured in this test")),
51
+ ),
44
52
  };
45
53
  });
46
54
 
@@ -188,9 +196,48 @@ describe("executeAndAwait worktree 失败收尾", () => {
188
196
  expect(archiveSpy).toHaveBeenCalledTimes(1);
189
197
  // store 现已强类型为 RecordStore → archive 入参为 ExecutionRecord,无需 `as` 断言。
190
198
  const archivedRecord = archiveSpy.mock.calls[0]![0];
191
- expect(archivedRecord.status).toBe("failed");
199
+ expect(archivedRecord.status).toBe("closed");
192
200
  // archive 后 record 已移出 running map → listRunning 空、getMutable 取不到。
193
201
  expect(store.listRunning()).toHaveLength(0);
194
202
  expect(store.getMutable(archivedRecord.id)).toBeUndefined();
195
203
  });
204
+
205
+ // ============================================================
206
+ // create-await 竞态守卫(Phase 2):create await 窗口内 dispose 抢先把
207
+ // record CAS 成 closed → 守卫 cleanup + throw cancelled(失败 throw 语义,
208
+ // SAR.run 的 catch 会转 AgentResult.error)。守卫 throw 不落在 try 内
209
+ // (否则被上方 catch 当 create 失败再走 finalizeFailed,对已 closed record 语义未定义)。
210
+ // ============================================================
211
+ it("守卫:create await 窗口内 dispose 抢先 → cleanup 被调 + throw cancelled(不进 runAndFinalize)", async () => {
212
+ const { service, worktreeManager } = setup();
213
+
214
+ const handle = Object.freeze({
215
+ path: "/tmp/wt-guard2",
216
+ branch: "pi-sub-guard2",
217
+ baseCommit: "abc123",
218
+ mainCwd: "/repo",
219
+ }) as Parameters<WorktreeManager["cleanup"]>[0];
220
+ let resolveCreate!: (h: unknown) => void;
221
+ vi.spyOn(worktreeManager, "create").mockImplementation(
222
+ () => new Promise((r) => { resolveCreate = r; }) as ReturnType<WorktreeManager["create"]>,
223
+ );
224
+ const cleanupSpy = vi.spyOn(worktreeManager, "cleanup").mockResolvedValue(undefined);
225
+
226
+ const execP = service.executeAndAwait({
227
+ task: "guard test",
228
+ slug: "guard-test",
229
+ worktree: true,
230
+ fork: true,
231
+ ctxModel,
232
+ });
233
+ // 微任务推进:record 已建 + executeAndAwait 挂在 pending create 上
234
+ await new Promise((r) => { setTimeout(r, 0); });
235
+ // dispose 抢先:CAS running → closed(此刻 worktreeHandle 仍 undefined,
236
+ // dispose 的 fire-and-forget cleanup 跳过)——守卫是唯一清理点
237
+ service.disposeAllRecords("parent-shutdown");
238
+ resolveCreate(handle);
239
+
240
+ await expect(execP).rejects.toThrow("cancelled during worktree creation");
241
+ expect(cleanupSpy).toHaveBeenCalledWith(handle);
242
+ });
196
243
  });
@@ -5,8 +5,8 @@
5
5
  // 用例:
6
6
  // D-032 background 进并发池(分层配额:max(1, maxConcurrent - depth))
7
7
  // D-033 execute 入口通用嵌套护栏(execCtxAls 计 fork+非 fork 嵌套,深度>MAX 拒)
8
- // 嵌套抑制 background onUpdate undefined(防 spinner 堆叠)
9
- // 节流清理 background finalize throttleState 无残留(clearThrottle 生效)
8
+ // (原「嵌套抑制 onUpdate」「节流清理 throttleState」用例组已随 onEventThrottled
9
+ // 死路径删除一并移除——swf-perf-impl ledger #22 / cleanup-slice TC4)
10
10
  //
11
11
  // ── mock 策略 ──
12
12
  //
@@ -18,7 +18,8 @@
18
18
  // mock 模式参考 run-spawn-integration.test.ts(该文件是 spawn 改造后的正确 mock 范式):
19
19
  // - node:child_process.spawn → FakeChild(EventEmitter + PassThrough),测试控制器
20
20
  // emit stdout JSON 行(header + SdkEvent)/ stderr / close 时序。
21
- // - node:child_process.execFileSync""(buildEnvBlock 的 git branch 调用避免副作用)。
21
+ // - node:child_process.execFileerr-first callback 默认兜底(buildEnvBlock 的 git
22
+ // branch 调用失败 → catch → branch="",避免副作用)。
22
23
  // - node:fs 同步方法 → mock(mkdirSync/existsSync/appendFileSync/writeFileSync/readdirSync),
23
24
  // 避免 sessionDir/sessionFile 触碰真实文件系统。
24
25
  // - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。
@@ -26,7 +27,7 @@
26
27
  // - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。
27
28
  //
28
29
  // 所有断言语义不变:它们测的是 SubagentService 的 **编排逻辑**
29
- // (pool.acquire / execCtxAls 深度 / onUpdate 抑制 / throttle 清理),这些逻辑
30
+ // (pool.acquire / execCtxAls 深度),这些逻辑
30
31
  // 无论事件来自 fakeSession.subscribe 还是 FakeChild.stdout 都一致。
31
32
 
32
33
  import type { PassThrough } from "node:stream";
@@ -60,7 +61,15 @@ vi.mock("node:child_process", async () => {
60
61
 
61
62
  return {
62
63
  spawn: vi.fn(() => new FakeChild()),
63
- execFileSync: vi.fn(() => ""), // buildEnvBlock 的 git branch 调用,返回空避免副作用
64
+ // buildEnvBlock 的 git branch 调用(execFile 异步):默认 err-first 兜底 → catch → branch=""
65
+ execFile: vi.fn(
66
+ (
67
+ _cmd: string,
68
+ _args: readonly string[],
69
+ _opts: unknown,
70
+ cb: (err: Error | null, stdout?: string, stderr?: string) => void,
71
+ ) => cb(new Error("execFile not configured in this test")),
72
+ ),
64
73
  };
65
74
  });
66
75
 
@@ -133,6 +142,7 @@ vi.mock("../temp-prompt.ts", () => ({
133
142
 
134
143
  import { spawn } from "node:child_process";
135
144
 
145
+ import { waitForSpawn } from "./helpers/spawn-mock.ts";
136
146
  import { ModelConfigService } from "../model-config-service.ts";
137
147
  import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
138
148
  import { MAX_FORK_DEPTH } from "../session-context-resolver.ts";
@@ -162,23 +172,6 @@ function lastSpawnedChild(): FakeChild {
162
172
  return result.value as FakeChild;
163
173
  }
164
174
 
165
- /**
166
- * 等待 execute → runSpawn 内部调到 spawn(拿到 child 控制器)。
167
- *
168
- * runSpawn 是 async,spawn 在 mkdirSync + writePromptToTempFile 之后才调(均有微任务/
169
- * I/O 延迟)。用 setInterval 轮询 mockSpawn.mock.results,比 vi.waitFor 在该 vitest 版本
170
- * 下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
171
- */
172
- async function waitForSpawn(timeoutMs = 1000): Promise<void> {
173
- const start = Date.now();
174
- while (mockSpawn.mock.results.length === 0) {
175
- if (Date.now() - start > timeoutMs) {
176
- throw new Error(`spawn was not called within ${timeoutMs}ms`);
177
- }
178
- await new Promise((r) => setTimeout(r, 2));
179
- }
180
- }
181
-
182
175
  // ============================================================
183
176
  // 辅助:FakeChild stdout 驱动(替代旧 fakeSession.subscribe 的事件注入)
184
177
  // ============================================================
@@ -271,7 +264,7 @@ describe("嵌套护栏 / 并发池 / 节流(D-030~D-033 回归锁)", () => {
271
264
 
272
265
  const execPromise = service.execute({ task: "bg in pool", ctxModel });
273
266
  // detached runAndFinalize → acquire。等 spawn 拿到 child 再驱动完成。
274
- await waitForSpawn();
267
+ await waitForSpawn(mockSpawn);
275
268
  await driveChildToCompletion(lastSpawnedChild());
276
269
 
277
270
  // 等 detached promise 链跑完(kickOffBackground 的 .then notify)
@@ -312,7 +305,7 @@ describe("嵌套护栏 / 并发池 / 节流(D-030~D-033 回归锁)", () => {
312
305
  const execPromise = execCtxAls.run({ recordId: "parent", depth: MAX_FORK_DEPTH - 1 }, () =>
313
306
  service.execute({ task: "at limit", ctxModel }),
314
307
  );
315
- await waitForSpawn();
308
+ await waitForSpawn(mockSpawn);
316
309
  await driveChildToCompletion(lastSpawnedChild(), [
317
310
  { type: "turn_end" },
318
311
  { type: "message_end", message: { usage: { input: 1 } } },
@@ -323,53 +316,8 @@ describe("嵌套护栏 / 并发池 / 节流(D-030~D-033 回归锁)", () => {
323
316
  });
324
317
 
325
318
  // ============================================================
326
- // 嵌套 sync onUpdate 抑制(nestingDepth>0 onUpdate undefined)
319
+ // 原嵌套抑制 onUpdate / 节流清理 throttleState 用例组(:322-355)已删除——
320
+ // onEventThrottled 死路径删除(swf-perf-impl ledger #22 / cleanup-slice TC4/IF14,
321
+ // 详见 .cw/swf-perf-impl/cleanup-slice-design.json)。恢复点:本 slice 前的 git 历史。
327
322
  // ============================================================
328
-
329
- it("[嵌套抑制] 嵌套 sync(execCtxAls depth>0)不回流 onUpdate", async () => {
330
- const { service } = setup();
331
-
332
- const execCtxAls = Reflect.get(service, "execCtxAls") as ExecCtxAls;
333
-
334
- const updates: unknown[] = [];
335
- const execPromise = execCtxAls.run({ recordId: "parent", depth: 1 }, () =>
336
- service.execute({ task: "nested sync", ctxModel, onUpdate: (d) => updates.push(d) }),
337
- );
338
- await waitForSpawn();
339
- // emit 会触发 onUpdate 的事件(tool_start/tool_end 是 TRIGGERING_EVENT),
340
- // 但嵌套层 onUpdate 被抑制(undefined)→ onEventThrottled 包装不挂载 → 0 次
341
- await driveChildToCompletion(lastSpawnedChild(), [
342
- { type: "tool_execution_start", toolCallId: "t1", toolName: "read" },
343
- { type: "tool_execution_end", toolCallId: "t1", toolName: "read" },
344
- { type: "message_end", message: { usage: { input: 1 } } },
345
- ]);
346
- await execPromise;
347
-
348
- // tool_end 是 TRIGGERING_EVENT,但嵌套层 onUpdate 被抑制(undefined)→ 0 次
349
- expect(updates).toHaveLength(0);
350
- });
351
-
352
- // ============================================================
353
- // clearThrottle:sync 完成后 throttleState 清理
354
- // ============================================================
355
-
356
- it("[节流清理] sync 完成后 throttleState 无残留(clearThrottle 生效)", async () => {
357
- const { service } = setup();
358
-
359
- const execPromise = service.execute({
360
- task: "throttle clear",
361
- ctxModel,
362
- onUpdate: () => {},
363
- });
364
- await waitForSpawn();
365
- await driveChildToCompletion(lastSpawnedChild(), [
366
- { type: "tool_execution_end", toolCallId: "t1", toolName: "read" },
367
- { type: "message_end", message: { usage: { input: 1 } } },
368
- ]);
369
- await execPromise;
370
-
371
- // finalizeRecord → clearThrottle 清掉该 record 的节流 entry(防 Map 无限增长 + trailing 误发陈旧)
372
- const throttleState = Reflect.get(service, "throttleState") as Map<string, unknown>;
373
- expect(throttleState.size).toBe(0);
374
- });
375
323
  });
@@ -10,8 +10,10 @@ import {
10
10
  getCurrentActivity,
11
11
  getEventLog,
12
12
  getFullText,
13
+ getFullTextFrom,
13
14
  getTotalUsage,
14
15
  markReconstructedStatus,
16
+ nextRoundBaseTurnIndex,
15
17
  project,
16
18
  projectLiveProgress,
17
19
  snapshot,
@@ -576,6 +578,152 @@ describe("getFullText", () => {
576
578
  });
577
579
  });
578
580
 
581
+ // ============================================================
582
+ // getFullTextFrom — 增量文本派生(round-base-increment wave1)
583
+ // ============================================================
584
+ describe("getFullTextFrom", () => {
585
+ it("C1TC2: from=0 与 getFullText 逐字节等价(fresh / 单 turn / 多 turn 混空 turn 三形态)", () => {
586
+ // 形态 1:fresh record(turns=[emptyTurn()])
587
+ const fresh = makeRecord();
588
+ expect(getFullTextFrom(fresh, 0)).toBe(getFullText(fresh));
589
+ expect(getFullTextFrom(fresh, 0)).toBe("");
590
+
591
+ // 形态 2:单 turn 有文本
592
+ const single = makeRecord();
593
+ updateFromEvent(single, { type: "text_delta", delta: "Hello world" });
594
+ expect(getFullTextFrom(single, 0)).toBe(getFullText(single));
595
+ expect(getFullTextFrom(single, 0)).toBe("Hello world");
596
+
597
+ // 形态 3:多 turn 混空 turn(closed 空 turn + 空白串 turn + 未闭合尾 turn)
598
+ const mixed = makeRecord();
599
+ updateFromEvent(mixed, { type: "text_delta", delta: "Turn 1" });
600
+ updateFromEvent(mixed, { type: "turn_end" });
601
+ updateFromEvent(mixed, { type: "turn_end" }); // 空 turn 2(closed)
602
+ updateFromEvent(mixed, { type: "text_delta", delta: " " }); // 空白串 turn 3
603
+ updateFromEvent(mixed, { type: "turn_end" });
604
+ updateFromEvent(mixed, { type: "text_delta", delta: "Turn 4" }); // 未闭合尾 turn
605
+ expect(getFullTextFrom(mixed, 0)).toBe(getFullText(mixed));
606
+ expect(getFullTextFrom(mixed, 0)).toBe("Turn 1\n\n \n\nTurn 4");
607
+ });
608
+
609
+ it("C1TC3: 多 turn 单轮增量——from=1 只含 Turn 2,对照全量含 Turn 1\\n\\nTurn 2", () => {
610
+ const r = makeRecord();
611
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
612
+ updateFromEvent(r, { type: "turn_end" });
613
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
614
+ expect(getFullTextFrom(r, 1)).toBe("Turn 2");
615
+ // 对照:全量 vs 增量差异锁定
616
+ expect(getFullText(r)).toBe("Turn 1\n\nTurn 2");
617
+ });
618
+
619
+ it("C1TC4: 空文本轮过滤(join 不多出空段)+ 空白串按非空计入增量", () => {
620
+ // turns: ["A"(closed), ""(closed), " "(closed), "B"(open)]
621
+ const r = makeRecord();
622
+ updateFromEvent(r, { type: "text_delta", delta: "A" });
623
+ updateFromEvent(r, { type: "turn_end" });
624
+ updateFromEvent(r, { type: "turn_end" }); // 空 turn(closed)
625
+ updateFromEvent(r, { type: "text_delta", delta: " " });
626
+ updateFromEvent(r, { type: "turn_end" });
627
+ updateFromEvent(r, { type: "text_delta", delta: "B" });
628
+ // from=2 起:空 turn 在 from 之前不参与;" " 按非空处理计入增量(filter 判据与 getFullText 逐字对齐)
629
+ expect(getFullTextFrom(r, 2)).toBe(" \n\nB");
630
+ // 空 turn 过滤:from=0 时不产出空段(join 不多出 \n\n 空段)
631
+ expect(getFullTextFrom(r, 0)).toBe("A\n\n \n\nB");
632
+ });
633
+
634
+ it("C1TC5: from >= turns.length 越界返回空串(不抛错、不回绕)", () => {
635
+ const r = makeRecord();
636
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
637
+ updateFromEvent(r, { type: "turn_end" });
638
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
639
+ expect(getFullTextFrom(r, r.turns.length)).toBe("");
640
+ expect(getFullTextFrom(r, r.turns.length + 5)).toBe("");
641
+ });
642
+
643
+ it("C1TC6: from 为负规范化为 0(与 from=0 / getFullText 等价,无 slice 负偏移语义)", () => {
644
+ const r = makeRecord();
645
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
646
+ updateFromEvent(r, { type: "turn_end" });
647
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
648
+ expect(getFullTextFrom(r, -1)).toBe(getFullTextFrom(r, 0));
649
+ expect(getFullTextFrom(r, -100)).toBe(getFullText(r));
650
+ expect(getFullTextFrom(r, -100)).toBe("Turn 1\n\nTurn 2");
651
+ });
652
+ });
653
+
654
+ // ============================================================
655
+ // nextRoundBaseTurnIndex — 轮次边界推进公式(round-base-increment wave1)
656
+ // ============================================================
657
+ describe("nextRoundBaseTurnIndex", () => {
658
+ it("C1TC7: 全闭合推进 length / fresh record([emptyTurn()] 单未闭合空 turn)返回 0", () => {
659
+ // 全闭合:2 个 closed turn → length
660
+ const r = makeRecord();
661
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
662
+ updateFromEvent(r, { type: "turn_end" });
663
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
664
+ updateFromEvent(r, { type: "turn_end" });
665
+ expect(r.turns.length).toBe(2);
666
+ expect(nextRoundBaseTurnIndex(r)).toBe(2);
667
+
668
+ // fresh 形态(createRecord:turns=[emptyTurn()] 单个未闭合空 turn)→ 0(ES6 重建回退语义)
669
+ const fresh = createRecord("fresh-1", {
670
+ agent: "worker",
671
+ model: "test-model",
672
+ thinkingLevel: undefined,
673
+ mode: "sync",
674
+ task: "t",
675
+ slug: "t",
676
+ startedAt: 1000,
677
+ rootSessionId: "sess-main",
678
+ parentRecordId: undefined,
679
+ depth: 0,
680
+ });
681
+ expect(fresh.turns).toHaveLength(1);
682
+ expect(nextRoundBaseTurnIndex(fresh)).toBe(0);
683
+ });
684
+
685
+ it("C1TC8 ⛔ ES1 探针: 滞后空 turn(防御分支)边界不丢下轮首段文本", () => {
686
+ // 防御分支构造(pi 现事件序下不可达——带 usage 的 message_end 恒先于 turn_end;探针
687
+ // 按防御分支构造仍有效,锁住公式在滞后交错下的正确性,防 pi 未来事件序变化)。
688
+ // 两层 usage 守卫:session-runner.ts 转发层 if (msg?.usage)(bare message_end 不转发)
689
+ // + execution-record.ts 累积层 if (event.usage)(bare message_end 不开 turn)——
690
+ // 故本探针的 message_end 必须携带 usage(如 { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 })才能开出空 turn。
691
+ const r = makeRecord();
692
+ updateFromEvent(r, { type: "text_delta", delta: "R1" });
693
+ updateFromEvent(r, { type: "turn_end" }); // 闭合本轮
694
+ updateFromEvent(r, {
695
+ type: "message_end",
696
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 },
697
+ }); // 滞后 message_end 开新空 turn
698
+ expect(r.turns.length).toBe(2);
699
+ expect(r.turns[1]!.closed).toBe(false);
700
+ expect(r.turns[1]!.text).toBe("");
701
+
702
+ // 推进:滞后空 turn 不计入边界(turns.length - 1,留在下一轮增量内)
703
+ const base = nextRoundBaseTurnIndex(r);
704
+ expect(base).toBe(1);
705
+
706
+ // 新轮 text_delta 复用该空 turn → 文本落在增量范围内,不丢
707
+ updateFromEvent(r, { type: "text_delta", delta: "R2" });
708
+ expect(r.turns.length).toBe(2); // 复用,未新开
709
+ expect(getFullTextFrom(r, base)).toBe("R2");
710
+
711
+ // 对照组:若直用 turns.length 推进,R2 被挤出 slice——被防住的丢文本负面行为锁定
712
+ expect(getFullTextFrom(r, r.turns.length)).toBe("");
713
+ });
714
+
715
+ it("C1TC9: 末 turn 未闭合且非空计入本轮(防御形态公式锁定,R1 观测哨触发条件)", () => {
716
+ // pi 现序不可达(agent_settled 在 agent_end 之后,error/aborted 也先 emit turn_end);
717
+ // 构造该防御形态锁死公式行为:非空文本计入本轮增量、不 defer 到下轮。
718
+ const r = makeRecord();
719
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
720
+ updateFromEvent(r, { type: "turn_end" });
721
+ updateFromEvent(r, { type: "text_delta", delta: "Unclosed tail" }); // 未闭合非空尾 turn
722
+ expect(nextRoundBaseTurnIndex(r)).toBe(r.turns.length);
723
+ expect(nextRoundBaseTurnIndex(r)).toBe(2);
724
+ });
725
+ });
726
+
579
727
  // ============================================================
580
728
  // getAllToolCalls / getTotalUsage — 聚合派生
581
729
  // ============================================================
@@ -957,3 +1105,54 @@ describe("projectLiveProgress (T3.21)", () => {
957
1105
  expect(result.lastError).toBe("something went wrong");
958
1106
  });
959
1107
  });
1108
+
1109
+ // ============================================================
1110
+ // tool_end running 索引 [perf]
1111
+ // ============================================================
1112
+ // 索引弹尾必须与旧实现「倒序全扫找最后一个 running 同名」语义等价:
1113
+ // 同名并发 toolCall 的 tool_end 交错到达时按 LIFO 配对,配对结果写回正确槽位。
1114
+ describe("tool_end running index [perf]", () => {
1115
+ const start = (record: ExecutionRecord, name: string, args: Record<string, unknown> = {}) =>
1116
+ updateFromEvent(record, { type: "tool_start", toolName: name, args });
1117
+
1118
+ const end = (record: ExecutionRecord, name: string, result: string) =>
1119
+ updateFromEvent(record, { type: "tool_end", toolName: name, result, isError: false });
1120
+
1121
+ it("同名并发两个 toolCall:tool_end LIFO 配对,结果写回各自槽位", () => {
1122
+ const record = makeRecord();
1123
+ start(record, "bash", { cmd: "first" });
1124
+ start(record, "bash", { cmd: "second" });
1125
+
1126
+ // 第一个 end 配对最后 push 的 running 同名(second)——与旧倒序全扫一致
1127
+ end(record, "bash", "result-for-second");
1128
+ const calls = record.turns[0]!.toolCalls;
1129
+ expect(calls).toHaveLength(2);
1130
+ expect(calls[0]).toMatchObject({ args: { cmd: "first" }, _status: "running" });
1131
+ expect(calls[1]).toMatchObject({ result: "result-for-second", _status: "done" });
1132
+
1133
+ // 第二个 end 配对剩下的 first
1134
+ end(record, "bash", "result-for-first");
1135
+ expect(calls[0]).toMatchObject({ result: "result-for-first", _status: "done" });
1136
+ expect(calls[1]).toMatchObject({ result: "result-for-second", _status: "done" });
1137
+ });
1138
+
1139
+ it("索引 miss(无 tool_start 的外部注入 tool_end)→ fallback 全扫后 push 幽灵条目", () => {
1140
+ const record = makeRecord();
1141
+ end(record, "external_tool", "injected");
1142
+ const calls = record.turns[0]!.toolCalls;
1143
+ expect(calls).toHaveLength(1);
1144
+ expect(calls[0]).toMatchObject({ toolName: "external_tool", result: "injected", _status: "done" });
1145
+ });
1146
+
1147
+ it("跨 turn 滞后 tool_end:索引跨 turn 命中(与旧全扫语义一致)", () => {
1148
+ const record = makeRecord();
1149
+ start(record, "read"); // turn 0 内 running
1150
+ updateFromEvent(record, { type: "turn_end" }); // 闭合 turn 0,开 turn 1
1151
+ start(record, "write"); // turn 1
1152
+ end(record, "write", "w-done"); // 正常配对 turn 1
1153
+ end(record, "read", "r-done"); // 滞后 end 配对 turn 0(跨 turn 兜底)
1154
+
1155
+ expect(record.turns[0]!.toolCalls[0]).toMatchObject({ toolName: "read", result: "r-done", _status: "done" });
1156
+ expect(record.turns[1]!.toolCalls[0]).toMatchObject({ toolName: "write", result: "w-done", _status: "done" });
1157
+ });
1158
+ });
@@ -25,7 +25,7 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
25
25
  getLogger: () => loggerMock,
26
26
  }));
27
27
 
28
- import { doFinalizeRecord } from "../finalize-record.ts";
28
+ import { doFinalizeRecord, doFinalizeRoundToIdle } from "../finalize-record.ts";
29
29
  import { ManifestStore } from "../manifest-store.ts";
30
30
  import type { AgentResult, ExecutionRecord } from "../types.ts";
31
31
 
@@ -89,35 +89,34 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
89
89
  store: { archive: vi.fn() } as never,
90
90
  modelService: {} as never,
91
91
  pi: { appendEntry: vi.fn() },
92
- clearThrottle: vi.fn(),
93
92
  emitUnregister: vi.fn(),
94
93
  };
95
94
  }
96
95
 
97
- it("status=cancelled → manifest 写 cancelled(不再归并 failed)", async () => {
96
+ it("status=closed + cancelled reason → manifest 写 closed(v4 B-1:cancelled 折入 closed)", async () => {
98
97
  const record = makeMinimalRecord({ id: "rec-cancelled" });
99
- await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "cancelled");
98
+ await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "cancelled");
100
99
 
101
100
  const manifest = await manifestStore.readManifest("rec-cancelled");
102
101
  expect(manifest).not.toBeNull();
103
- // 关键断言:cancelled 直接透传,不是 "failed"
104
- expect(manifest?.status).toBe("cancelled");
102
+ // 关键断言:v4 B-1 manifest status 恒写 closed(cancelled 区分靠 tombstone sidecar)
103
+ expect(manifest?.status).toBe("closed");
105
104
  });
106
105
 
107
- it("status=done → manifest 写 completed", async () => {
106
+ it("status=closed + user-close → manifest 写 closed", async () => {
108
107
  const record = makeMinimalRecord({ id: "rec-done" });
109
- await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "done");
108
+ await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "user-close");
110
109
 
111
110
  const manifest = await manifestStore.readManifest("rec-done");
112
- expect(manifest?.status).toBe("completed");
111
+ expect(manifest?.status).toBe("closed");
113
112
  });
114
113
 
115
- it("status=failed → manifest 写 failed", async () => {
114
+ it("status=closed + gc → manifest 写 closed", async () => {
116
115
  const record = makeMinimalRecord({ id: "rec-failed" });
117
- await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "failed");
116
+ await doFinalizeRecord(makeDeps(), record, makeMinimalResult(), "closed", "gc");
118
117
 
119
118
  const manifest = await manifestStore.readManifest("rec-failed");
120
- expect(manifest?.status).toBe("failed");
119
+ expect(manifest?.status).toBe("closed");
121
120
  });
122
121
 
123
122
  it("manifest write 抛错时 cleanup-first 顺序仍执行(Step 3 before Step 4 throw)", async () => {
@@ -148,7 +147,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
148
147
 
149
148
  // ── 核心 claim 1:不抛错(cleanup-first → manifest write 失败不 throw)──
150
149
  await expect(
151
- doFinalizeRecord(deps, record, makeMinimalResult(), "done"),
150
+ doFinalizeRecord(deps, record, makeMinimalResult(), "closed"),
152
151
  ).resolves.toBeUndefined();
153
152
 
154
153
  // ── 核心 claim 2:Step 3 cleanup 先执行 —— finalized marker 真实写入 ──
@@ -158,7 +157,7 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
158
157
  expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
159
158
 
160
159
  // ── 核心 claim 4:pending-notifications 注销仍触发(emitUnregister)──
161
- expect(deps.emitUnregister).toHaveBeenCalledWith("rec-cleanup-first", "done");
160
+ expect(deps.emitUnregister).toHaveBeenCalledWith("rec-cleanup-first", "closed");
162
161
 
163
162
  // ── 核心 claim 5:manifest 写失败被 logger.error 记录(含 record id + error)──
164
163
  expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining("manifest 写入失败"));
@@ -184,3 +183,160 @@ describe("doFinalizeRecord — manifest status 透传 (M3 4 态)", () => {
184
183
  loggerMock.error.mockClear();
185
184
  });
186
185
  });
186
+
187
+ // ============================================================
188
+ // doFinalizeRoundToIdle — chatMode 轮次完成进 idle(M2-A)
189
+ // ============================================================
190
+
191
+ describe("doFinalizeRoundToIdle — chatMode 轮次完成进 idle (M2-A)", () => {
192
+ let tmpDir: string;
193
+ let manifestStore: ManifestStore;
194
+
195
+ beforeEach(() => {
196
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "finalize-idle-test-"));
197
+ manifestStore = new ManifestStore(tmpDir);
198
+ });
199
+
200
+ afterEach(() => {
201
+ fs.rmSync(tmpDir, { recursive: true, force: true });
202
+ });
203
+
204
+ /** 构造 FinalizeDeps:worktreeManager.cleanup / store.archive 为 vi.fn 以断言「不调」。 */
205
+ function makeDeps() {
206
+ return {
207
+ manifestStore,
208
+ worktreeManager: { cleanup: vi.fn(), collectPatch: vi.fn() } as never,
209
+ store: { archive: vi.fn() } as never,
210
+ modelService: {} as never,
211
+ pi: { appendEntry: vi.fn() } as never,
212
+ emitUnregister: vi.fn(),
213
+ };
214
+ }
215
+
216
+ it("record 带 sessionFile → 删 .alive + record.status=running + round 0→1", async () => {
217
+ const sessionFile = path.join(tmpDir, "session.jsonl");
218
+ // 预写 .alive marker(验证 doFinalizeRoundToIdle 删除它——进程已回收)
219
+ fs.writeFileSync(
220
+ `${sessionFile}.alive`,
221
+ `${JSON.stringify({ pid: 99999, id: "rec-idle", startedAt: 1000 })}\n`,
222
+ "utf-8",
223
+ );
224
+ const record = makeMinimalRecord({ id: "rec-idle", sessionFile, chatMode: true, round: 0 });
225
+ // tryTransition 已把 status 设为 done,模拟 runAndFinalize 调用前的状态
226
+ record.status = "closed";
227
+
228
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
229
+
230
+ // 状态机(v4 B-1:旧 idle 折入 running,finalizeRoundToIdle 设 running)
231
+ expect(record.status).toBe("running");
232
+ expect(record.round).toBe(1);
233
+ // .alive marker 被删(进程已 SIGTERM 回收)
234
+ expect(fs.existsSync(`${sessionFile}.alive`)).toBe(false);
235
+ });
236
+
237
+ it("record.round 已为 N → round 变 N+1", async () => {
238
+ const record = makeMinimalRecord({ id: "rec-round", round: 3 });
239
+ record.status = "closed";
240
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
241
+ expect(record.round).toBe(4);
242
+ expect(record.status).toBe("running");
243
+ });
244
+
245
+ it("不调 store.archive(record 留内存,getMutable 仍可查)", async () => {
246
+ const deps = makeDeps();
247
+ const record = makeMinimalRecord({ id: "rec-noarchive" });
248
+ record.status = "closed";
249
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
250
+ expect(deps.store.archive).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it("不 cleanup worktree(即使 record 带 worktreeHandle——保留对话模式工作目录)", async () => {
254
+ const deps = makeDeps();
255
+ const record = makeMinimalRecord({
256
+ id: "rec-noworktree",
257
+ worktreeHandle: { path: "/tmp/x", branch: "b", baseCommit: "c", mainCwd: "/tmp" } as never,
258
+ });
259
+ record.status = "closed";
260
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
261
+ expect(deps.worktreeManager.cleanup).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it("emitUnregister 被调(status=running,进程已死从 pending 活跃差集移除)", async () => {
265
+ const deps = makeDeps();
266
+ const record = makeMinimalRecord({ id: "rec-emit" });
267
+ record.status = "closed";
268
+ await doFinalizeRoundToIdle(deps, record, makeMinimalResult());
269
+ expect(deps.emitUnregister).toHaveBeenCalledWith("rec-emit", "running");
270
+ });
271
+
272
+ it("不调 completeRecord:record 不冻结(endedAt / agentResult 仍 undefined)", async () => {
273
+ const record = makeMinimalRecord({ id: "rec-nofreeze" });
274
+ record.status = "closed";
275
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
276
+ expect(record.endedAt).toBeUndefined();
277
+ expect(record.agentResult).toBeUndefined();
278
+ });
279
+
280
+ it("不写 manifest(idle 非终态,manifest 是终态诊断辅助)", async () => {
281
+ const record = makeMinimalRecord({ id: "rec-nomanifest" });
282
+ record.status = "closed";
283
+ await doFinalizeRoundToIdle(makeDeps(), record, makeMinimalResult());
284
+ const manifest = await manifestStore.readManifest("rec-nomanifest");
285
+ expect(manifest).toBeNull();
286
+ });
287
+
288
+ it("MF-2: record.result 设为 result.text(否则 notifier idle 回复恒为 (empty),G1/G2 不成立)", async () => {
289
+ const record = makeMinimalRecord({ id: "rec-result" });
290
+ record.status = "closed";
291
+ const result = makeMinimalResult();
292
+ result.text = "review done, found 3 issues";
293
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
294
+ // MF-2:record.result 被 result.text 填充,notifier idle 分支读取后携带回复正文
295
+ expect(record.result).toBe("review done, found 3 issues");
296
+ });
297
+
298
+ it("MF-2 兑底:失败轮次 result.text 为空时用 result.error 填 record.result(MF-6 回退 idle 路径)", async () => {
299
+ const record = makeMinimalRecord({ id: "rec-result-err" });
300
+ record.status = "closed";
301
+ const result = makeMinimalResult();
302
+ result.text = "";
303
+ result.success = false;
304
+ result.error = "spawn timeout";
305
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
306
+ // 失败轮次的 notify 需可读:result.text 空 → 兑底用 error
307
+ expect(record.result).toBe("round did not complete: spawn timeout");
308
+ });
309
+
310
+ it("C1TC10: chatMode 空增量轮占位——record.result 固定 (no output this round),不含上一轮文本(D5)", async () => {
311
+ const record = makeMinimalRecord({ id: "rec-increment-empty", chatMode: true });
312
+ record.status = "closed";
313
+ // 预置上一轮通知文本(模拟增量语义前的 record.result 残留)
314
+ record.result = "PREV-ROUND-TEXT";
315
+ const result = makeMinimalResult();
316
+ result.text = "";
317
+ result.success = true;
318
+ result.error = undefined;
319
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
320
+ // 空增量轮通知不含上一轮文本:沿用旧值会让父 agent 误读为原样重复回复(D5 判定)
321
+ expect(record.result).toBe("(no output this round)");
322
+ expect(record.result).not.toContain("PREV-ROUND-TEXT");
323
+ });
324
+
325
+ it("C1TC11: 非 chatMode 空文本沿用旧值——one-shot 空文本完成 record.result 保持 undefined(G2/G4)", async () => {
326
+ const record = makeMinimalRecord({ id: "rec-oneshot-empty", chatMode: false });
327
+ record.status = "closed";
328
+ // one-shot 成功空文本完成路径(collectResult getFullText 返回 ""、success=true):
329
+ // result 前值 undefined
330
+ record.result = undefined;
331
+ const result = makeMinimalResult();
332
+ result.text = "";
333
+ result.success = true;
334
+ result.error = undefined;
335
+ await doFinalizeRoundToIdle(makeDeps(), record, result);
336
+ // 非 chatMode 侧维持现状(第三分支 record.chatMode ? 占位 : record.result)
337
+ expect(record.result).toBeUndefined();
338
+ // notifier buildLlmContent 的 record.result ?? "(empty)" 确定性链保 G4:
339
+ // 通知文案逐字节产出 "completed. Result:\n(empty)",不漂移为 "(no output this round)"
340
+ expect(record.result ?? "(empty)").toBe("(empty)");
341
+ });
342
+ });