@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
package/src/index.ts CHANGED
@@ -30,12 +30,15 @@ import {
30
30
  ModelConfigService,
31
31
  setModelConfigService,
32
32
  } from "./execution/model-config-service.ts";
33
+ import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./execution/session-reconstructor.ts";
34
+ import type { ExecutionMode, SubagentRecord } from "./execution/types.ts";
33
35
  import { maybeCleanupExpiredSessionFiles } from "./execution/session-file-gc.ts";
34
36
  import {
35
37
  getSubagentService,
36
38
  setSubagentService,
37
39
  SubagentService,
38
40
  } from "./execution/subagent-service.ts";
41
+ import { killAllSpawnedChildren } from "./execution/session-runner.ts";
39
42
  import { SubprocessAgentRunner } from "./execution/subprocess-agent-runner.ts";
40
43
  import { WorktreeManager } from "./execution/worktree-manager.ts";
41
44
  import { setupSubagentListInjector } from "./injectors/subagent-list-injector.ts";
@@ -43,17 +46,23 @@ import { setupWorkflowListInjector } from "./injectors/workflow-list-injector.ts
43
46
  import { renderBgNotifyMessage } from "./interface/bg-notify-render.ts";
44
47
  import { registerWorkflowsCommand } from "./interface/commands.ts";
45
48
  import { toGuiCtx } from "./interface/gui-mappers.ts";
46
- import { notifyDone } from "./interface/helpers.ts";
49
+ import { notifyDone, trackNotifiedRunId } from "./interface/helpers.ts";
47
50
  import { registerSubagentTool } from "./interface/subagent-tool.ts";
48
51
  // ═══ interface/ 层(tools/commands/tui 合并) ═══
49
52
  import { registerSubagentsCommand } from "./interface/subagents.ts";
50
53
  import { registerWorkflowTool } from "./interface/tool-workflow.ts";
51
54
  import { registerWorkflowScriptTool } from "./interface/tool-workflow-script.ts";
52
55
  import { JsonlRunStore } from "./orchestration/jsonl-run-store.ts";
56
+ import { clearSkillPathCache } from "./orchestration/skill-discovery.ts";
53
57
  // ═══ orchestration/ 层(workflow engine + infra) ═══
54
58
  import type { LauncherDeps } from "./orchestration/launcher.ts";
55
59
  import { executeNestedWorkflow, runAndWait, type WorkflowRunResult } from "./orchestration/launcher.ts";
56
- import { pauseRun, scheduleTimeBudget } from "./orchestration/lifecycle.ts";
60
+ import {
61
+ evictDoneRunsBeyondCap,
62
+ MAX_RETAINED_DONE_RUNS,
63
+ scheduleTimeBudget,
64
+ terminateRunningRuns,
65
+ } from "./orchestration/lifecycle.ts";
57
66
  import type { WorkflowRun } from "./orchestration/models/workflow-run.ts";
58
67
  import { WorkerHostImpl } from "./orchestration/worker-host.ts";
59
68
  import { WorkflowScriptRegistryImpl } from "./orchestration/workflow-script-registry-impl.ts";
@@ -76,6 +85,75 @@ declare module "@earendil-works/pi-coding-agent" {
76
85
  // 模块级 logger(setPiHandle 注入后自动走 appendEntry)
77
86
  const logger = getLogger("subagents");
78
87
 
88
+ // ── subagent 状态快照格式化 ──
89
+ //
90
+ // [v4 A-6] before_agent_start 注入 hook 已删(活跃 subagent 清单改由 agent 按需调
91
+ // action:'list' 拉取,消除每 loop 注入的上下文税与盲点)。本函数保留为纯格式化工具:
92
+ // before-agent-start-injection / parent-child-matrix 测试覆盖其正确性,未来 list
93
+ // 视图或其他注入点可复用。
94
+
95
+ /** 活跃 subagent 数量上限(超过截断显示)。 */
96
+ const MAX_STATUS_INJECTION = 10;
97
+
98
+ /**
99
+ * 将活跃 subagent record 格式化为一行一条的快照文本。
100
+ *
101
+ * 格式:
102
+ * [subagent-status] N active subagents:
103
+ * - sa-xxx (slug): running, rounds 0
104
+ * - sa-yyy (slug): idle, rounds 3
105
+ * +2 more, use action:'list'
106
+ *
107
+ * @param records 已筛选的活跃 record(running + idle)
108
+ */
109
+ export function formatSubagentStatusSnapshot(records: SubagentRecord[]): string {
110
+ const lines = [`[subagent-status] ${records.length} active subagent${records.length === 1 ? "" : "s"}:`];
111
+ const shown = records.slice(0, MAX_STATUS_INJECTION);
112
+ for (const r of shown) {
113
+ const slug = r.slug || r.agent;
114
+ const roundPart = r.round !== undefined && r.round > 0 ? `, rounds ${r.round}` : "";
115
+ lines.push(`- ${r.id} (${slug}): ${r.status}${roundPart}`);
116
+ }
117
+ const remaining = records.length - MAX_STATUS_INJECTION;
118
+ if (remaining > 0) {
119
+ lines.push(`+${remaining} more, use action:'list'`);
120
+ }
121
+ return lines.join("\n");
122
+ }
123
+
124
+ // ═══ [V2 决策 7 防线 i] process 级 shutdown hook ═══
125
+ //
126
+ // session_shutdown 是 pi 的 async hook,进程被 SIGTERM/SIGINT 强杀或崩溃时来不及
127
+ // 触发;sync 子进程(controller 为 undefined,abortRunningControllers 跳过它们)会
128
+ // 泄漏为孤儿。process.on 兜底显式 killAllSpawnedChildren 收割全部活子进程。
129
+ // guard 防多信号叠加(如 SIGINT 后又 beforeExit)重复 kill。
130
+ let processShutdownHookFired = false;
131
+
132
+ function reapSpawnedChildrenOnShutdown(): void {
133
+ if (processShutdownHookFired) return;
134
+ processShutdownHookFired = true;
135
+ try {
136
+ killAllSpawnedChildren("SIGTERM");
137
+ } catch (err) {
138
+ // best-effort:收割失败不阻断退出流程——debug 留痕(孤儿子进程排查线索),
139
+ // 不静默吞错,对齐「错误必须可操作」。
140
+ logger.debug(
141
+ "[subagents] process shutdown reap best-effort failed (killAllSpawnedChildren SIGTERM)",
142
+ { reason: err instanceof Error ? err.message : String(err) },
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * 测试钩子:重置 module 级 shutdown guard。
149
+ *
150
+ * 对齐 lifecycle-manager._resetLifecycleState 模式——processShutdownHookFired 是
151
+ * module 级单例状态,跨 test 持久,单测需显式重置以验证 idempotent 行为。
152
+ */
153
+ export function _resetProcessShutdownGuardForTest(): void {
154
+ processShutdownHookFired = false;
155
+ }
156
+
79
157
  export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
80
158
  // 注入 pi handle 给全局 extension-logger,让深层代码(best-effort / error-recovery)
81
159
  // 的 getLogger("subagents") 也能走 appendEntry。
@@ -187,7 +265,23 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
187
265
  runner: state.runner,
188
266
  runs: state.runs,
189
267
  registry,
190
- onRunDone: (run: WorkflowRun) => notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx)),
268
+ // onRunDone 是全部 done 路径的单点汇聚(abortRun + error-recovery),顺序固化为
269
+ // notify → track → evict:notifyDone 先发完整聚合通知(淘汰后聚合根仍在闭包参数
270
+ // run 引用上不受影响),trackNotifiedRunId 有界化去重窗口,最后裁剪 done run 内存。
271
+ // 本轮 run 的 completedAt 在 transition("done") 时同步设为当前时刻=全局最新,
272
+ // 恒在保留端——结构性保证其不被自身触发的裁剪淘汰,无需 protectRunId。
273
+ onRunDone: (run: WorkflowRun) => {
274
+ notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx));
275
+ trackNotifiedRunId(notifiedRunIds, run.runId);
276
+ const evicted = evictDoneRunsBeyondCap(state.runs, MAX_RETAINED_DONE_RUNS);
277
+ if (evicted > 0) {
278
+ logger.debug("[subagent-workflow] evicted done runs beyond cap", {
279
+ evicted,
280
+ keep: MAX_RETAINED_DONE_RUNS,
281
+ sessionId: lsRef.lastSessionId,
282
+ });
283
+ }
284
+ },
191
285
  eventBus: pi.events,
192
286
  scheduleTimeBudget: (runId: string, budgetTimeMs: number) =>
193
287
  scheduleTimeBudget(runId, deps, budgetTimeMs),
@@ -217,6 +311,55 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
217
311
  const sessionId = ctx.sessionManager.getSessionId();
218
312
  lsRef.lastSessionId = sessionId;
219
313
 
314
+ // skill 路径两级缓存 session 级失效:pi 同进程可能有多个 session(TUI /new、/fork),
315
+ // 运行中安装的 skill 需对新 session 可见(含曾 miss 缓存的 undefined 条目与 npm 新装
316
+ // 包的候选目录)。session 内复用收益不变(IF8/DM3 消重发生在同 session 的重复调用)。
317
+ clearSkillPathCache();
318
+
319
+ // ── [M4] identity 子进程写入(V2 决策 5)──
320
+ // 子进程经 env(PI_SUBAGENT_*)接收自己的 identity,在 session_start 用 pi.appendEntry
321
+ // 写 subagent-identity custom entry。pi 自动生成 id/parentId → message tree 连续。
322
+ // 旧实现父进程 fs.appendFileSync 补写的 custom entry 缺 id/parentId → 污染 _buildIndex
323
+ // leafId 指针 → message tree 断成两棵 → 多轮对话丢上下文(bug 根因)。
324
+ // 主/子进程判定:PI_SUBAGENT_SELF_RECORD_ID 仅 session-runner spawn 子进程时注入,
325
+ // 主进程无此 env → 跳过(identity 只在子进程写一次)。
326
+ const selfRecordId = process.env.PI_SUBAGENT_SELF_RECORD_ID;
327
+ if (selfRecordId) {
328
+ try {
329
+ const modeEnv = process.env.PI_SUBAGENT_MODE;
330
+ // ExecutionMode 联合窄化:父进程经 env 注入(record.mode 恒为 "background"),
331
+ // 运行时校验合法值,非法兜底 background(避免裸 cast,符合 taste/no-unsafe-cast)。
332
+ const mode: ExecutionMode = modeEnv === "background" ? modeEnv : "background";
333
+ const identity: SubagentIdentityData = {
334
+ id: selfRecordId,
335
+ agent: process.env.PI_SUBAGENT_AGENT ?? "",
336
+ mode,
337
+ task: process.env.PI_SUBAGENT_TASK ?? "",
338
+ slug: process.env.PI_SUBAGENT_SLUG,
339
+ startedAt: Number(process.env.PI_SUBAGENT_STARTED_AT ?? Date.now()),
340
+ rootSessionId: process.env.PI_SUBAGENT_ROOT_SESSION_ID,
341
+ parentRecordId: process.env.PI_SUBAGENT_PARENT_RECORD_ID,
342
+ depth:
343
+ process.env.PI_SUBAGENT_DEPTH !== undefined
344
+ ? Number(process.env.PI_SUBAGENT_DEPTH)
345
+ : undefined,
346
+ forkDepth:
347
+ process.env.PI_SUBAGENT_FORK_DEPTH !== undefined
348
+ ? Number(process.env.PI_SUBAGENT_FORK_DEPTH)
349
+ : undefined,
350
+ chatMode: process.env.PI_SUBAGENT_CHAT_MODE === "true",
351
+ // [review round2] worktree 隔离标志(session-runner 注入):跨重启重建路径据此
352
+ // 拒绝续聊(handle 不可序列化,reattach 不可行)。
353
+ worktree: process.env.PI_SUBAGENT_WORKTREE === "true",
354
+ };
355
+ pi.appendEntry(IDENTITY_CUSTOM_TYPE, identity);
356
+ } catch (err) {
357
+ logger.warn("[subagents] identity appendEntry failed in session_start", {
358
+ reason: err instanceof Error ? err.message : String(err),
359
+ });
360
+ }
361
+ }
362
+
220
363
  // ── subagents 域:双 Service 装配 ──
221
364
  const existingService = getSubagentService();
222
365
  const existingModelService = getModelConfigService();
@@ -267,6 +410,9 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
267
410
  setSubagentService(service);
268
411
  }
269
412
 
413
+ // S-2: 启动 idle record GC 定时器(30 天 TTL,每小时检查一次)
414
+ service.startGcTimer();
415
+
270
416
  cachedMainSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
271
417
 
272
418
  try {
@@ -292,7 +438,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
292
438
 
293
439
  try {
294
440
  const wtm = new WorktreeManager(agentDir);
295
- wtm.scan();
441
+ await wtm.scan();
296
442
  } catch (err) {
297
443
  logger.warn("[subagents] worktree reaper scan failed", {
298
444
  reason: err instanceof Error ? err.message : String(err),
@@ -328,6 +474,22 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
328
474
  }
329
475
  runs.set(run.runId, run);
330
476
  }
477
+ // done run 内存有界性:loadAll 全量重水合后立即裁剪到 K。kill-9 恢复(上方
478
+ // running → transition("done","failed"))的 run completedAt 为 transition 时刻
479
+ // (当前时间=全局最新)参与排序且必在保留端;多条恢复 run 同 ms completedAt →
480
+ // tie 稳定排序。淘汰只 delete runs Map 条目——磁盘 state 文件与
481
+ // workflow-state-link 指针条目均不动(历史审计保留);下次 session_start loadAll
482
+ // 从指针全量重水合后再次裁剪,该循环每次 session 启动重复且可接受:内存峰值只在
483
+ // 启动期,常驻 O(K + 活跃 run)。消除启动峰值需指针 compaction,属 append+replay
484
+ // 长期方案问题域,非本范围。
485
+ const evicted = evictDoneRunsBeyondCap(runs, MAX_RETAINED_DONE_RUNS);
486
+ if (evicted > 0) {
487
+ logger.debug("[subagent-workflow] evicted done runs beyond cap after loadAll", {
488
+ evicted,
489
+ keep: MAX_RETAINED_DONE_RUNS,
490
+ sessionId,
491
+ });
492
+ }
331
493
  } catch (err) {
332
494
  // QMF-4 fix: store.loadAll 失败是关键路径错误,workflow 域将未初始化
333
495
  logger.error("[subagent-workflow] store.loadAll failed, workflow domain uninitialized", {
@@ -371,7 +533,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
371
533
  });
372
534
 
373
535
  // ════════════════════════════════════════════════════════════
374
- // session_tree:切分支前 pause 所有 running run
536
+ // session_tree:切分支前终止所有 running run(一次性生命周期——切走即作废)
375
537
  // ════════════════════════════════════════════════════════════
376
538
  pi.on("session_tree", async (_event: SessionTreeEvent, ctx: ExtensionContext) => {
377
539
  const sessionId = ctx.sessionManager.getSessionId();
@@ -379,33 +541,90 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
379
541
 
380
542
  const state = sessionState.get(sessionId);
381
543
  if (state) {
382
- for (const run of state.runs.values()) {
383
- if (run.state.status === "running") {
384
- try {
385
- await pauseRun(run.runId, makeDeps(state, ctx));
386
- } catch (err) {
387
- bestEffort(err, "pauseRun (session_tree handler)");
388
- }
389
- }
544
+ // 一次性生命周期(D-2):running run done,failed 落盘(helper 内部自过滤
545
+ // running,单 run 失败不中断其余)。此处不再挂起待恢复。
546
+ try {
547
+ await terminateRunningRuns(makeDeps(state, ctx), "Session switched: run terminated");
548
+ } catch (err) {
549
+ bestEffort(err, "terminateRunningRuns (session_tree handler)");
390
550
  }
391
551
  }
392
552
  });
393
553
 
394
554
  // ════════════════════════════════════════════════════════════
395
- // session_shutdown:dispose subagents + pause workflows + cleanup
555
+ // SP-4: session_before_fork(/fork)/ session_before_switch(/new)级联关闭
556
+ // ════════════════════════════════════════════════════════════
557
+ // 主 session /fork 或 /new 时,清理旧 record(disposeAllRecords:CAS 转终态 +
558
+ // archive + worktree 清理)。before 事件在 session 替换前触发,确保旧 session 的
559
+ // subagent 在新 session 创建前被清理(随后的 session_shutdown → dispose 收割子进程)。
560
+ //
561
+ // [M2 修复] 旧实现把 /new 级联挂在 session_before_tree 上——SDK 中该事件只由
562
+ // AgentSession.navigateTree()(/tree 同 session 分支切换)触发,/new 走
563
+ // session_before_switch(reason:"new") + session_shutdown(reason:"new"),从不触发
564
+ // before_tree。后果双向:/new 级联是死代码;普通 /tree 分支导航反而误杀全部活跃
565
+ // subagent。现 /new 改挂 session_before_switch(reason==="new"),before_tree handler
566
+ // 移除(/tree 是同 session 内导航,record/子进程归属不变,无级联关闭诉求)。
567
+ pi.on("session_before_fork", (_event, _ctx) => {
568
+ const service = getSubagentService();
569
+ if (service) {
570
+ const count = service.onParentFork();
571
+ if (count > 0) {
572
+ logger.warn(`[subagents] /fork 级联关闭 ${count} 个 subagent`);
573
+ }
574
+ }
575
+ });
576
+
577
+ pi.on("session_before_switch", (event, _ctx) => {
578
+ // /new(reason:"new")创建全新 session → 级联关闭旧 record。
579
+ // reason:"resume"(/resume /import 回到已有 session)不级联:record 按 rootSessionId
580
+ // 归属隔离,跨 session 读写由 store 过滤守卫,无需销毁。
581
+ if (event.reason !== "new") return;
582
+ const service = getSubagentService();
583
+ if (service) {
584
+ const count = service.onParentNew();
585
+ if (count > 0) {
586
+ logger.warn(`[subagents] /new 级联关闭 ${count} 个 subagent`);
587
+ }
588
+ }
589
+ });
590
+
591
+ // ════════════════════════════════════════════════════════════
592
+ // session_shutdown:dispose subagents + terminate workflows + store 收尾 + cleanup
593
+ //
594
+ // store 收尾:每 session 的 JsonlRunStore 在 terminateRunningRuns 之后 dispose(刷
595
+ // pending 去抖批 + await in-flight 链,见 W2C5)。R3 声明:SIGTERM/SIGINT 走下方
596
+ // process handler 不触发本路径,pending 去抖丢失等价崩溃链(重启后 kill-9 恢复
597
+ // 收编 running 残留——终态/创建均冷路径已落盘,丢的只有 ≤saveDebounceMs 的
598
+ // running 尾巴,ES1 已接受);不做 best-effort SIGTERM dispose(需同步 IO 改造,
599
+ // 超出 wave 边界)。
396
600
  // ════════════════════════════════════════════════════════════
397
601
  pi.on("session_shutdown", async (_event: SessionShutdownEvent, _ctx: ExtensionContext) => {
398
602
  // ── subagents 域:dispose SubagentService ──
399
603
  getSubagentService()?.dispose();
400
604
 
401
- // ── workflow 域:pause 所有 running run + 清理 temp files ──
605
+ // ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
402
606
  // H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——
403
607
  // 防御 session 切换但 session_tree 未先触发导致 lastSessionId 指向已删除 session 的情况)。
404
608
  for (const [sessionId, state] of sessionState) {
405
- const running = Array.from(state.runs.values()).filter((r) => r.state.status === "running");
406
- await Promise.allSettled(
407
- running.map((run) => pauseRun(run.runId, makeDeps(state, _ctx))),
408
- );
609
+ // 编排顺序(W2C5):terminate(await,failed 落盘——重启后 kill-9 恢复不误判)
610
+ // → store.dispose(await,刷 pending 去抖批 + await in-flight 链,关「shutdown
611
+ // 时刻 pending 去抖写丢失」窗口)→ delete。terminate 的 running 过滤在 helper
612
+ // 内部(单 run 失败不中断其余);外层 try/catch 兜底防单 session 异常中断后续
613
+ // session 条目的 dispose + delete(对齐原 allSettled 的不中断语义)。
614
+ try {
615
+ await terminateRunningRuns(makeDeps(state, _ctx), "Session shutdown: run terminated");
616
+ } catch (err) {
617
+ bestEffort(err, "terminateRunningRuns (session_shutdown handler)");
618
+ }
619
+ // dispose 自身恒 resolve,catch 兜底防御——handler 内抛错会中断后续 session
620
+ // 条目清理。不留静默吞错(错误必须可操作):debug 留痕带 sessionId/sessionDir,
621
+ // 排查「shutdown 后 run 状态不落盘」类问题时有迹可循。
622
+ await state.store.dispose().catch((err: unknown) => {
623
+ logger.debug(
624
+ `[subagent-workflow] session_shutdown store.dispose failed (sessionId=${sessionId}, sessionDir=${state.sessionDir})`,
625
+ { reason: err instanceof Error ? err.message : String(err) },
626
+ );
627
+ });
409
628
  sessionState.delete(sessionId);
410
629
  }
411
630
 
@@ -422,6 +641,45 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
422
641
  dialogQueue.rejectAll();
423
642
  });
424
643
 
644
+ // ════════════════════════════════════════════════════════════
645
+ // [V2 决策 7 防线 i] process 级 shutdown hook(显式收割三道防线之一)
646
+ //
647
+ // 上方 session_shutdown(pi async hook)在进程被 SIGTERM/SIGINT 强杀或崩溃时
648
+ // 不触发,此处 process.on 兜底确保 sync 子进程被收割(防线 i:shutdown 时显式
649
+ // SIGTERM 全部 activation)。
650
+ //
651
+ // - SIGTERM:pi 各 mode(rpc/interactive/print)自带 SIGTERM handler 负责退出编排,
652
+ // 本 extension 的 handler 只做收割 + 设 exitCode(不 re-raise、不抢 pi 的退出语义;
653
+ // xyz-agent 桌面 supervisor 用 SIGTERM 杀 pi 走这条路)。
654
+ // - SIGINT:pi 本体不注册常规 SIGINT handler(interactive/print/rpc 均 SIGTERM only),
655
+ // 依赖 Node 默认终止。本 extension 注册 listener 即取消默认终止——若只设 exitCode,
656
+ // 本地 pi CLI 的 Ctrl-C 杀不死进程(TUI/stdin/agent loop 仍在事件循环)。故收割
657
+ // 完成后 re-raise 恢复默认终止,见下方 sigintHandler。
658
+ // - beforeExit 是退出前最后事件,不 exit(自然退出)。
659
+ // - idempotent guard(reapSpawnedChildrenOnShutdown 内)防多信号叠加重复 kill。
660
+ //
661
+ // 防线 iii(activate 互斥)已接入:subagent-service.ts 冷路径 resume 调
662
+ // acquireActivateLock(含 30s 超时兜底,见 lifecycle-manager.ts ACTIVATE_LOCK_TIMEOUT_MS)。
663
+ // 防线 ii(启动 scanOrphanProcesses)骨架就位,启动时接入待实现。
664
+ // ════════════════════════════════════════════════════════════
665
+ process.on("SIGTERM", () => {
666
+ reapSpawnedChildrenOnShutdown();
667
+ // S-3: 改用 process.exitCode 而非 process.exit(0),让子进程 cleanup 完成后再自然退出。
668
+ // process.exit(0) 会立即终止,可能在 reapSpawnedChildrenOnShutdown 完成前截断。
669
+ // 退出编排归 pi 自身的 SIGTERM handler(rpc-mode 会主动退出)。
670
+ process.exitCode = 0;
671
+ });
672
+ // [review 修复] SIGINT re-raise:收割同步完成后,先移除自身 listener 再向自身重发
673
+ // SIGINT,恢复 Node 默认终止。不 removeListener 直接 kill(process.pid) 会再次进入
674
+ // 本 handler 递归;移除后无其他 SIGINT listener(pi 不注册)→ 默认行为终止进程。
675
+ const sigintHandler = (): void => {
676
+ reapSpawnedChildrenOnShutdown();
677
+ process.removeListener("SIGINT", sigintHandler);
678
+ process.kill(process.pid, "SIGINT");
679
+ };
680
+ process.on("SIGINT", sigintHandler);
681
+ process.on("beforeExit", reapSpawnedChildrenOnShutdown);
682
+
425
683
  // ════════════════════════════════════════════════════════════
426
684
  // pi.__workflowRun(D-8 签名)
427
685
  // ════════════════════════════════════════════════════════════
@@ -51,6 +51,19 @@ const logger = getLogger("injector");
51
51
  */
52
52
  let agentCache: AgentEntry[] | null = null;
53
53
 
54
+ /**
55
+ * 注入块渲染缓存(与 agentCache 同步更新):before_agent_start 每个 turn 都要注入,
56
+ * formatAgentList(escapeXml 多趟正则 × 全部字段)在数据不变时输出完全相同——渲染
57
+ * 一次随缓存复用,turn 热路径零重复计算。
58
+ */
59
+ let agentInjectionCache: string | null = null;
60
+
61
+ /** agentCache 唯一写点:数据与渲染缓存同步更新(null 清空两者)。 */
62
+ function setAgentCache(entries: AgentEntry[] | null): void {
63
+ agentCache = entries;
64
+ agentInjectionCache = entries !== null ? formatAgentList(entries) : null;
65
+ }
66
+
54
67
  /** 从 .md frontmatter 提取的最小 agent 信息(m5:+ when/examples 路由样本;S1:+ path) */
55
68
  export interface AgentEntry {
56
69
  name: string;
@@ -189,9 +202,11 @@ export function setupSubagentListInjector(pi: ExtensionAPI): void {
189
202
  "session_start",
190
203
  async (_event: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
191
204
  try {
192
- agentCache = await discoverAllAgents(
193
- findWorkspaceRoot(ctx.cwd),
194
- getAgentDir(),
205
+ setAgentCache(
206
+ await discoverAllAgents(
207
+ findWorkspaceRoot(ctx.cwd),
208
+ getAgentDir(),
209
+ ),
195
210
  );
196
211
  } catch (err) {
197
212
  // fail-safe:发现异常不阻断 session,缓存保持 null(before_agent_start 会 fallback)
@@ -211,12 +226,15 @@ export function setupSubagentListInjector(pi: ExtensionAPI): void {
211
226
  try {
212
227
  // 读缓存;miss(session_start 未触发/缓存被清)则 fallback 重新发现+赋值
213
228
  if (agentCache === null) {
214
- agentCache = await discoverAllAgents(
215
- findWorkspaceRoot(ctx.cwd),
216
- getAgentDir(),
229
+ setAgentCache(
230
+ await discoverAllAgents(
231
+ findWorkspaceRoot(ctx.cwd),
232
+ getAgentDir(),
233
+ ),
217
234
  );
218
235
  }
219
- const injection = formatAgentList(agentCache);
236
+ // agentInjectionCache agentCache 不变量同步(setAgentCache 保证),直接复用
237
+ const injection = agentInjectionCache;
220
238
  if (!injection) return;
221
239
  return { systemPrompt: event.systemPrompt + injection };
222
240
  } catch (err) {
@@ -230,7 +248,7 @@ export function setupSubagentListInjector(pi: ExtensionAPI): void {
230
248
  pi.on(
231
249
  "session_shutdown",
232
250
  (_event: SessionShutdownEvent, _ctx: ExtensionContext): void => {
233
- agentCache = null;
251
+ setAgentCache(null);
234
252
  },
235
253
  );
236
254
  }
@@ -43,6 +43,18 @@ const logger = getLogger("injector");
43
43
  */
44
44
  let workflowCache: WorkflowEntry[] | null = null;
45
45
 
46
+ /**
47
+ * 注入块渲染缓存(与 workflowCache 同步更新):before_agent_start 每个 turn 都要注入,
48
+ * formatWorkflowList 在数据不变时输出完全相同——渲染一次随缓存复用。
49
+ */
50
+ let workflowInjectionCache: string | null = null;
51
+
52
+ /** workflowCache 唯一写点:数据与渲染缓存同步更新(null 清空两者)。 */
53
+ function setWorkflowCache(entries: WorkflowEntry[] | null): void {
54
+ workflowCache = entries;
55
+ workflowInjectionCache = entries !== null ? formatWorkflowList(entries) : null;
56
+ }
57
+
46
58
  /** 注入段中单个 workflow 的最大描述长度(控制每 turn prompt 体积) */
47
59
  const MAX_DESC_LEN = 160;
48
60
 
@@ -172,9 +184,11 @@ export function setupWorkflowListInjector(pi: ExtensionAPI): void {
172
184
  "session_start",
173
185
  async (_event: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
174
186
  try {
175
- workflowCache = await discoverAllWorkflows(
176
- findWorkspaceRoot(ctx.cwd),
177
- getAgentDir(),
187
+ setWorkflowCache(
188
+ await discoverAllWorkflows(
189
+ findWorkspaceRoot(ctx.cwd),
190
+ getAgentDir(),
191
+ ),
178
192
  );
179
193
  } catch (err) {
180
194
  // fail-safe:发现异常不阻断 session,缓存保持 null(before_agent_start 会 fallback)
@@ -194,12 +208,15 @@ export function setupWorkflowListInjector(pi: ExtensionAPI): void {
194
208
  try {
195
209
  // 读缓存;miss(session_start 未触发/缓存被清)则 fallback 重新发现+赋值
196
210
  if (workflowCache === null) {
197
- workflowCache = await discoverAllWorkflows(
198
- findWorkspaceRoot(ctx.cwd),
199
- getAgentDir(),
211
+ setWorkflowCache(
212
+ await discoverAllWorkflows(
213
+ findWorkspaceRoot(ctx.cwd),
214
+ getAgentDir(),
215
+ ),
200
216
  );
201
217
  }
202
- const injection = formatWorkflowList(workflowCache);
218
+ // workflowInjectionCache workflowCache 不变量同步(setWorkflowCache 保证),直接复用
219
+ const injection = workflowInjectionCache;
203
220
  if (!injection) return;
204
221
  return { systemPrompt: event.systemPrompt + injection };
205
222
  } catch (err) {
@@ -213,7 +230,7 @@ export function setupWorkflowListInjector(pi: ExtensionAPI): void {
213
230
  pi.on(
214
231
  "session_shutdown",
215
232
  (_event: SessionShutdownEvent, _ctx: ExtensionContext): void => {
216
- workflowCache = null;
233
+ setWorkflowCache(null);
217
234
  },
218
235
  );
219
236
  }
@@ -31,12 +31,16 @@ function extractDescription(src: string): string {
31
31
  const DESCRIPTION = extractDescription(SUBAGENT_TOOL_SRC);
32
32
 
33
33
  describe("subagent tool description — 行为约束器(非功能说明书)", () => {
34
- it("词数 ≤ 550(高风险 description 密度上限)", () => {
34
+ it("词数 ≤ 800(高风险 description 密度上限)", () => {
35
35
  // 高风险 tool 的 description 应聚焦约束而非功能铺陈;过长会稀释信号。
36
- // 上限从 400 放宽到 550:补了 JSON 调用正例段(start/list/cancel 三 action 完整 JSON),
37
- // 正例对弱模型首次调用用对参数的价值 > 节省这点 description 预算。
36
+ // 上限演进:400 550(补 start/list/cancel 三 action 完整 JSON 正例)→
37
+ // 650(M2-B3 新增 message/close action + conversation 对话模式 + 例子)→
38
+ // 800(递归指导:树形判据 + 自包含 task + 独立验收 + fork 成本警告)。
39
+ // 800 是断言的真实预算(标题与本值一致);留 ≥60 词余量供后续措辞微调,
40
+ // 逼近上限时优先精简正文而非再放宽断言。
41
+ // 每次 action 扩展必然增加必需描述;正例对弱模型首次用对参数的价值 > 节省 description 预算。
38
42
  const words = DESCRIPTION.trim().split(/\s+/).filter(Boolean).length;
39
- expect(words).toBeLessThanOrEqual(550);
43
+ expect(words).toBeLessThanOrEqual(800);
40
44
  });
41
45
 
42
46
  it("含 'When to delegate' 调用条件段(何时委派 vs 自己做)", () => {
@@ -78,6 +82,15 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
78
82
  expect(DESCRIPTION).toMatch(/Depth: N\/10/);
79
83
  });
80
84
 
85
+ it("递归指导含树形判据 + 自包含 task + 独立验收 + 深度定位", () => {
86
+ // 递归不是「能套就套」,必须有明确使用判据,否则 LLM 会滥用深递归。
87
+ const lower = DESCRIPTION.toLowerCase();
88
+ expect(lower).toContain("tree-shaped"); // 树形判据
89
+ expect(lower).toContain("self-contained"); // task 自包含
90
+ expect(lower).toContain("acceptance criteria"); // 独立验收
91
+ expect(lower).toContain("safety rail"); // 深度定位:10 是护栏非预算
92
+ });
93
+
81
94
  it("保留 executionMode sequential 的 CRITICAL 说明", () => {
82
95
  // sequential 是关键执行语义,删了会导致 LLM 误以为并行可用。
83
96
  expect(DESCRIPTION).toMatch(/CRITICAL/i);
@@ -108,7 +121,7 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
108
121
  // 工具描述只保留通用指引(2026-08 通用化重构)。
109
122
  expect(SUBAGENT_TOOL_SRC).toContain("available_subagents");
110
123
  // 通用化约束:不写死任何具体 agent 名(名字随 agents/*.md 动态变化)
111
- expect(SUBAGENT_TOOL_SRC).not.toMatch(/orchestrator|code-reviewer|context-builder/);
124
+ expect(SUBAGENT_TOOL_SRC).not.toMatch(/orchestrator|code-reviewer|context-builder|worker/);
112
125
  });
113
126
 
114
127
  it("Anti-patterns 段明确 list/cancel 仍 nested(防过度泛化 flatten)", () => {
@@ -63,56 +63,58 @@ describe("renderSubagentCall — 拍平形态提取(regression for wave 3 flat
63
63
 
64
64
  it("从顶层 args 提取显式 agent 名", () => {
65
65
  const out = renderText(renderSubagentCall(
66
- { action: "start", agent: "worker", task: "do stuff", slug: "x" },
66
+ { action: "start", agent: "coder", task: "do stuff", slug: "x" },
67
67
  makeTheme() as never,
68
68
  CTX,
69
69
  ));
70
- expect(out).toContain("worker");
70
+ expect(out).toContain("coder");
71
71
  });
72
72
 
73
73
  it("从顶层 args 提取 slug 并在 agent 后展示", () => {
74
74
  const out = renderText(renderSubagentCall(
75
- { action: "start", agent: "worker", task: "do stuff", slug: "fix-login" },
75
+ { action: "start", agent: "coder", task: "do stuff", slug: "fix-login" },
76
76
  makeTheme() as never,
77
77
  CTX,
78
78
  ));
79
- expect(out).toContain("worker");
79
+ expect(out).toContain("coder");
80
80
  expect(out).toContain("fix-login");
81
81
  });
82
82
 
83
- it("从顶层 args 提取 task 作为 preview 行(含换行)", () => {
83
+ it("task 不再渲染为 preview 行(标题块只有单行)", () => {
84
84
  const out = renderText(renderSubagentCall(
85
- { action: "start", agent: "worker", task: "Analyze the bug in parser", slug: "fix-parser" },
85
+ { action: "start", agent: "coder", task: "Analyze the bug in parser", slug: "fix-parser" },
86
86
  makeTheme() as never,
87
87
  CTX,
88
88
  ));
89
- // task preview 出现在结果里(首行非空,截断到 60 字符)
90
- expect(out).toContain("Analyze the bug in parser");
89
+ // task 预览行已移除——task 内容不进对话流(完整 task 在 /subagents 详情可见)
90
+ expect(out).not.toContain("Analyze the bug in parser");
91
+ expect(out).not.toContain("\n");
91
92
  });
92
93
 
93
- it("task 含换行时只取首个非空行(不破坏单行渲染)", () => {
94
+ it("task 含换行时不进渲染(无 preview 泄漏)", () => {
94
95
  const out = renderText(renderSubagentCall(
95
96
  { action: "start", task: "first line\nsecond line", slug: "x" },
96
97
  makeTheme() as never,
97
98
  CTX,
98
99
  ));
99
- expect(out).toContain("first line");
100
+ expect(out).not.toContain("first line");
100
101
  expect(out).not.toContain("second line");
102
+ expect(out).not.toContain("\n");
101
103
  });
102
104
 
103
105
  // 关键回归:若有人把提取路径改回 args.startParam,这些顶层调用都会失败
104
- // (agent/slug/task 取不到,全用默认值)。此测试用顶层数据形态锁住 flatten。
106
+ // (agent/slug 取不到,全用默认值)。此测试用顶层数据形态锁住 flatten。
107
+ // task 不在断言里——task 已不渲染(上方用例锁定)。
105
108
  it("REGRESSION: 顶层 args 形态完整提取(防止回退到 startParam envelope)", () => {
106
109
  const out = renderText(renderSubagentCall(
107
110
  { action: "start", agent: "researcher", task: "search docs", slug: "search-docs" },
108
111
  makeTheme() as never,
109
112
  CTX,
110
113
  ));
111
- // 三个字段都应被提取(默认值 fallback 也能过单字段断言,但同时命中的
114
+ // agent + slug 都应被提取(默认值 fallback 也能过单字段断言,但同时命中的
112
115
  // 概率只有联合 fallback 才有——researcher/search-docs 都不是默认值)
113
116
  expect(out).toContain("researcher");
114
117
  expect(out).toContain("search-docs");
115
- expect(out).toContain("search docs");
116
118
  });
117
119
 
118
120
  it("args 缺所有字段时不崩(最防御)", () => {