@zhushanwen/pi-subagent-workflow 8.5.0 → 8.6.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 (92) hide show
  1. package/package.json +7 -6
  2. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  3. package/src/execution/__tests__/chat-engine-routing.test.ts +6 -2
  4. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  5. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  6. package/src/execution/__tests__/execution-record.test.ts +110 -0
  7. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  8. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  9. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  10. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  11. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  12. package/src/execution/__tests__/list-fields.test.ts +45 -14
  13. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  14. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  15. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  16. package/src/execution/__tests__/output-collector.test.ts +299 -2
  17. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  18. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  19. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  20. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  21. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  22. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  23. package/src/execution/__tests__/subprocess-agent-runner.test.ts +94 -1
  24. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  25. package/src/execution/__tests__/tool-action.test.ts +92 -1
  26. package/src/execution/agent-registry.ts +6 -0
  27. package/src/execution/argv-mirror.ts +5 -1
  28. package/src/execution/concurrency-pool.ts +1 -1
  29. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +13 -0
  30. package/src/execution/engine/engines/zcode/zcode-engine.ts +11 -1
  31. package/src/execution/engine/types.ts +6 -1
  32. package/src/execution/execute-options-mapper.ts +8 -7
  33. package/src/execution/execution-record.ts +60 -1
  34. package/src/execution/lifecycle-manager.ts +23 -1
  35. package/src/execution/model-config-service.ts +16 -1
  36. package/src/execution/model-resolver.ts +31 -59
  37. package/src/execution/notifier.ts +105 -35
  38. package/src/execution/notify-ledger.ts +580 -0
  39. package/src/execution/output-collector.ts +143 -3
  40. package/src/execution/session-runner.ts +304 -71
  41. package/src/execution/subagent-service.ts +24 -2
  42. package/src/execution/subprocess-agent-runner.ts +14 -0
  43. package/src/execution/types.ts +68 -5
  44. package/src/execution/ui-request-queue.ts +14 -4
  45. package/src/index.ts +54 -1
  46. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  47. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  48. package/src/interface/bg-notify-render.ts +33 -12
  49. package/src/interface/helpers.ts +2 -2
  50. package/src/interface/subagent-actions.ts +26 -9
  51. package/src/interface/subagent-tool-schema.ts +156 -0
  52. package/src/interface/subagent-tool.ts +56 -125
  53. package/src/interface/subagents.ts +2 -2
  54. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +16 -3
  55. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  56. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  57. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  58. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  59. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  60. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  61. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  62. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  63. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  64. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  65. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  66. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  67. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  68. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +21 -2
  69. package/src/orchestration/agent-opts-resolver.ts +104 -23
  70. package/src/orchestration/error-recovery.ts +189 -33
  71. package/src/orchestration/execute-agent-call.ts +39 -0
  72. package/src/orchestration/jsonl-run-store.ts +121 -7
  73. package/src/orchestration/launcher.ts +60 -15
  74. package/src/orchestration/lifecycle.ts +10 -7
  75. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  76. package/src/orchestration/models/budget.ts +5 -35
  77. package/src/orchestration/models/run-runtime.ts +24 -9
  78. package/src/orchestration/models/types.ts +9 -0
  79. package/src/orchestration/script-lint.ts +1 -1
  80. package/src/orchestration/skill-discovery.ts +31 -8
  81. package/src/orchestration/worker-script-builder.ts +16 -3
  82. package/src/shared/__tests__/model-ref.test.ts +306 -0
  83. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  84. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  85. package/src/shared/model-ref.ts +286 -0
  86. package/src/shared/schema-env.ts +44 -0
  87. package/src/shared/schema-jsonify.ts +6 -4
  88. package/src/shared/timer-delay.ts +54 -0
  89. package/workflows/review-fix-loop-utils.cjs +9 -7
  90. package/workflows/review-fix-loop.js +20 -12
  91. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  92. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -89,6 +89,10 @@ export class FakeChild extends EventEmitter {
89
89
  stdin = new PassThrough();
90
90
  killed = false;
91
91
  killSignal: string | undefined;
92
+ // [race-F4] SIGKILL 升级判定读真 ChildProcess 的 exitCode/signalCode(未退出时均为
93
+ // null)——FakeChild 同形提供,测试可赋值模拟已死进程(如 deliverMessage 写后死检测)。
94
+ exitCode: number | null = null;
95
+ signalCode: string | null = null;
92
96
  kill(sig?: string): boolean {
93
97
  this.killed = true;
94
98
  this.killSignal = sig;
@@ -18,7 +18,7 @@
18
18
  // 但**没有断言 setUiRequestHandler 被调用**——本测试补这个断言,且覆盖 existing 路径
19
19
  // (既有测试 fixed getSubagentService() => null,只能走 new 分支)。
20
20
 
21
- import { beforeEach, describe, expect, it, vi } from "vitest";
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
22
 
23
23
  // ── mock modules(在 import 前声明;路径相对 src/execution/__tests/) ──
24
24
  //
@@ -191,8 +191,9 @@ import { WorkflowRun } from "../../orchestration/models/workflow-run.ts";
191
191
  // ── helpers ──
192
192
 
193
193
  /** 创建可观察的 mock ExtensionAPI,捕获 session_start / session_shutdown handler。
194
- * Proxy 兜底:未显式处理的 pi.xxx 返回 noop,避免抛错。 */
195
- function createMockPi(): {
194
+ * Proxy 兜底:未显式处理的 pi.xxx 返回 noop,避免抛错。
195
+ * spies(可选):U2 通知账本用例注入 appendEntry / sendMessage 观察桩。 */
196
+ function createMockPi(spies?: { appendEntry?: (customType: string, data: unknown) => void; sendMessage?: (message: unknown, options?: unknown) => void }): {
196
197
  pi: ExtensionAPI;
197
198
  getSessionStartHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
198
199
  getSessionShutdownHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
@@ -216,7 +217,8 @@ function createMockPi(): {
216
217
  };
217
218
  }
218
219
  if (prop === "events") return events;
219
- if (prop === "appendEntry") return noop;
220
+ if (prop === "appendEntry") return spies?.appendEntry ?? noop;
221
+ if (prop === "sendMessage") return spies?.sendMessage ?? noop;
220
222
  if (prop === "registerMessageRenderer") return noop;
221
223
  return noop;
222
224
  },
@@ -228,14 +230,16 @@ function createMockPi(): {
228
230
  };
229
231
  }
230
232
 
231
- /** 最小 ExtensionContext mock。mode 由参数控制(决定 handler 注入行为)。 */
232
- function createMockCtx(mode: "tui" | "rpc" | "json" | "print"): Record<string, unknown> {
233
+ /** 最小 ExtensionContext mock。mode 由参数控制(决定 handler 注入行为)。
234
+ * entries(可选):U2 通知账本用例注入 sessionManager.getEntries 返回值
235
+ * (模拟重启前落盘的 ledger/ack entry)。 */
236
+ function createMockCtx(mode: "tui" | "rpc" | "json" | "print", entries?: unknown[]): Record<string, unknown> {
233
237
  const sessionManager = {
234
238
  getSessionId: () => "session-inject-1",
235
239
  getSessionFile: () => "/home/user/.pi/agent/sessions/session-inject-1.jsonl",
236
240
  getSessionDir: () => "/home/user/.pi/agent/sessions",
237
241
  getCwd: () => "/home/user/project",
238
- getEntries: () => [],
242
+ getEntries: () => entries ?? [],
239
243
  getBranch: () => [],
240
244
  getLeafId: () => null,
241
245
  getLeafEntry: () => undefined,
@@ -256,6 +260,7 @@ function createMockCtx(mode: "tui" | "rpc" | "json" | "print"): Record<string, u
256
260
  model: undefined,
257
261
  sessionManager,
258
262
  ui,
263
+ isIdle: () => true,
259
264
  };
260
265
  }
261
266
 
@@ -591,3 +596,77 @@ describe("W-3: done run 淘汰接线(loadAll 裁剪 + onRunDone 裁剪)", ()
591
596
  }
592
597
  });
593
598
  });
599
+
600
+ // ============================================================
601
+ // [U2] session_start 通知账本装配 + 重启恢复钩子(设计 D4)
602
+ // ============================================================
603
+ //
604
+ // mock session 文件(ctx.sessionManager.getEntries)含 ledger/ack entry →
605
+ // session_start handler 装配账本并重放差集:未销账号经 pi.sendMessage({triggerTurn:true})
606
+ // 单通道重投;已销账零重发。bind 顺序在 service.initSession 之前(notifier.notify
607
+ // 经模块级绑定消费账本)。
608
+
609
+ import {
610
+ _resetNotifyLedgerForTest,
611
+ NOTIFY_ACK_CUSTOM_TYPE,
612
+ NOTIFY_CUSTOM_TYPE,
613
+ NOTIFY_LEDGER_CUSTOM_TYPE,
614
+ } from "../notify-ledger.ts";
615
+
616
+ describe("session_start 通知账本恢复钩子(U2 B-ledger)", () => {
617
+ afterEach(() => {
618
+ _resetNotifyLedgerForTest();
619
+ });
620
+
621
+ it("mock session 含 ledger/ack entry → 未销账号重放(单通道 triggerTurn),已销账零重发", async () => {
622
+ const appendEntryCalls: Array<{ customType: string; data: unknown }> = [];
623
+ const sendMessageCalls: Array<{ message: unknown; options: unknown }> = [];
624
+ const { pi, getSessionStartHandler } = createMockPi({
625
+ appendEntry: (customType, data) => {
626
+ appendEntryCalls.push({ customType, data });
627
+ },
628
+ sendMessage: (message, options) => {
629
+ sendMessageCalls.push({ message, options });
630
+ },
631
+ });
632
+ subagentsExtension(pi);
633
+
634
+ // 模拟重启前落盘:2 条 ledger entry(s-a 已销账 + s-b 未销账)+ 1 条 ack
635
+ const entries: unknown[] = [
636
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a", content: "ca", record: { notifyId: "s-a" } } },
637
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-b", content: "cb", record: { notifyId: "s-b" } } },
638
+ { type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a" } },
639
+ ];
640
+ const handler = getSessionStartHandler();
641
+ expect(handler).toBeDefined();
642
+ await handler!({ type: "session_start" }, createMockCtx("rpc", entries));
643
+
644
+ // 未销账 s-b 单条重放:sendMessage 恰 1 次,options 恰 {triggerTurn:true}
645
+ expect(sendMessageCalls).toHaveLength(1);
646
+ expect(sendMessageCalls[0]!.options).toEqual({ triggerTurn: true });
647
+ const sent = sendMessageCalls[0]!.message as { customType: string; content: string; details?: { notifyId?: string } };
648
+ expect(sent.customType).toBe(NOTIFY_CUSTOM_TYPE);
649
+ expect(sent.content).toBe("cb");
650
+ expect(sent.details?.notifyId).toBe("s-b");
651
+ // 已销账 s-a 零重发(不追加新 ledger entry,不重投)
652
+ expect(appendEntryCalls.filter((c) => c.customType === NOTIFY_LEDGER_CUSTOM_TYPE)).toHaveLength(0);
653
+ });
654
+
655
+ it("全部已销账(差集为空)→ 零重放零发送", async () => {
656
+ const sendMessageCalls: unknown[] = [];
657
+ const { pi, getSessionStartHandler } = createMockPi({
658
+ sendMessage: () => {
659
+ sendMessageCalls.push({});
660
+ },
661
+ });
662
+ subagentsExtension(pi);
663
+
664
+ const entries: unknown[] = [
665
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-x", content: "cx", record: { notifyId: "s-x" } } },
666
+ { type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-x" } },
667
+ ];
668
+ await getSessionStartHandler()!({ type: "session_start" }, createMockCtx("rpc", entries));
669
+
670
+ expect(sendMessageCalls).toHaveLength(0);
671
+ });
672
+ });
@@ -30,11 +30,15 @@ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
30
30
  beforeEach(() => {
31
31
  vi.useFakeTimers();
32
32
  _resetLifecycleState();
33
+ // [F-4 同源修复] env 隔离:「默认超时」用例依赖 XYZ_SUBAGENT_IDLE_TIMEOUT_MS
34
+ // 未设基线(getEnvIdleTimeoutMs 会覆盖 DEFAULT),宿主 export 即假红。空串 = 未设。
35
+ vi.stubEnv("XYZ_SUBAGENT_IDLE_TIMEOUT_MS", "");
33
36
  });
34
37
 
35
38
  afterEach(() => {
36
39
  _resetLifecycleState();
37
40
  vi.useRealTimers();
41
+ vi.unstubAllEnvs();
38
42
  });
39
43
 
40
44
  // ============================================================
@@ -92,6 +96,48 @@ describe("lifecycle-manager — V2 §5.2 模块 1", () => {
92
96
  vi.advanceTimersByTime(1);
93
97
  expect(onTimeout).toHaveBeenCalledTimes(1);
94
98
  });
99
+
100
+ it("显式禁用值(0/负数)→ 不挂 timer(预算语义对齐:idle GC 可显式关闭;旧实现 0 落成 setTimeout(0) 立即 kill)", () => {
101
+ const onTimeout = vi.fn();
102
+ armIdleTimer("sa-disable-0", onTimeout, 0);
103
+ expect(hasIdleTimer("sa-disable-0")).toBe(false);
104
+ armIdleTimer("sa-disable-neg", onTimeout, -1);
105
+ expect(hasIdleTimer("sa-disable-neg")).toBe(false);
106
+
107
+ vi.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT_MS * 2);
108
+ expect(onTimeout).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("显式禁用值顺带 disarm 已有 armed timer(禁用不形同虚设)", () => {
112
+ const onTimeout = vi.fn();
113
+ armIdleTimer("sa-disable-late", onTimeout, 1000);
114
+ expect(hasIdleTimer("sa-disable-late")).toBe(true);
115
+ armIdleTimer("sa-disable-late", onTimeout, 0);
116
+ expect(hasIdleTimer("sa-disable-late")).toBe(false);
117
+ vi.advanceTimersByTime(2000);
118
+ expect(onTimeout).not.toHaveBeenCalled();
119
+ });
120
+
121
+ // [U1] setTimeout 2^31-1 溢出 fail-fast:溢出 delay 被 Node 置 1ms 立即触发
122
+ //(「长空闲保活」变「立即 kill」),arm 入口拦截且错误含上限值与恢复指引。
123
+ // 显式禁用通道(<=0)不受影响(上方用例已锁)。
124
+ it("idleTimeoutMs 溢出(>2^31-1)→ fail-fast throw,不挂 timer", () => {
125
+ const onTimeout = vi.fn();
126
+ expect(() => armIdleTimer("sa-overflow", onTimeout, 3_000_000_000)).toThrowError(/2147483647/);
127
+ expect(() => armIdleTimer("sa-overflow", onTimeout, Number.MAX_SAFE_INTEGER)).toThrowError(
128
+ /Recovery/,
129
+ );
130
+ expect(hasIdleTimer("sa-overflow")).toBe(false);
131
+ vi.advanceTimersByTime(3_000_000_000);
132
+ expect(onTimeout).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("env XYZ_SUBAGENT_IDLE_TIMEOUT_MS 溢出 → fail-fast throw(arm 入口统一拦截)", () => {
136
+ vi.stubEnv("XYZ_SUBAGENT_IDLE_TIMEOUT_MS", "3000000000");
137
+ const onTimeout = vi.fn();
138
+ expect(() => armIdleTimer("sa-env-overflow", onTimeout)).toThrowError(/2147483647/);
139
+ expect(hasIdleTimer("sa-env-overflow")).toBe(false);
140
+ });
95
141
  });
96
142
 
97
143
  // ============================================================
@@ -1,11 +1,12 @@
1
1
  // src/execution/__tests__/list-fields.test.ts
2
2
  //
3
- // [v4 A-6] recordToListItem 字段派生单测。
3
+ // [v4 A-6] recordToListItem 字段派生单测;[U3 C-outcome] closedReason 改锚 outcome。
4
4
  //
5
- // 验证 list 输出的新增字段派生正确:
5
+ // 验证 list 输出的字段派生正确:
6
6
  // - parent:从 record.parentRecordId 派生(顶层 record → undefined)
7
7
  // - resumable:从 isResumable 派生(running && 无活进程句柄)
8
- // - closedReason:透传 record.closedReason(SP-4 级联关闭告知替代)
8
+ // - outcome:一等终态语义(projectOutcome 唯一出口);closedReason 退出对外 JSON,
9
+ // 存量/重建 record(无 outcome 字段)由 deriveOutcome(closedReason, error) 兜底
9
10
  //
10
11
  // Mock 策略:vi.mock lifecycle-predicates 的 isResumable,控制其返回值模拟「有/无
11
12
  // 活进程句柄」两种 running 子态。isResumable 真实逻辑(running && !hasLiveProcessHandle)
@@ -21,7 +22,7 @@ vi.mock("../../execution/lifecycle-predicates.ts", () => ({
21
22
 
22
23
  import { recordToListItem } from "../../interface/subagent-actions.ts";
23
24
  import { isResumable } from "../../execution/lifecycle-predicates.ts";
24
- import type { ClosedReason, SubagentRecord } from "../types.ts";
25
+ import type { SubagentRecord } from "../types.ts";
25
26
 
26
27
  // ── SubagentRecord stub 工厂(最小合法 record) ──
27
28
 
@@ -51,7 +52,7 @@ function makeRecord(over: Partial<SubagentRecord> = {}): SubagentRecord {
51
52
  };
52
53
  }
53
54
 
54
- describe("recordToListItem — parent/resumable/closedReason (v4 A-6)", () => {
55
+ describe("recordToListItem — parent/resumable (v4 A-6)", () => {
55
56
  // ═══ parent:从 record.parentRecordId 派生 ═══
56
57
 
57
58
  it("parent:嵌套 record(parentRecordId='sa-A')→ parent='sa-A'", () => {
@@ -84,26 +85,56 @@ describe("recordToListItem — parent/resumable/closedReason (v4 A-6)", () => {
84
85
  expect(recordToListItem(rec).resumable).toBe(false);
85
86
  });
86
87
 
87
- // ═══ closedReason:透传 record.closedReason ═══
88
+ // ═══ outcome:一等终态披露(U3 C-outcome,projectOutcome 唯一出口)═══
88
89
 
89
- it("closedReason:closed + closedReason='parent-fork' → 透传", () => {
90
+ it("outcome:closed + closedReason='parent-fork' + 合成 error 'failed'(D6 显式取舍:父进程关闭未完成即失败,勿改 cancelled)", () => {
90
91
  const rec = makeRecord({
91
92
  status: "closed",
92
- closedReason: "parent-fork" as ClosedReason,
93
+ closedReason: "parent-fork",
94
+ error: "closed due to parent-fork",
93
95
  });
94
- expect(recordToListItem(rec).closedReason).toBe("parent-fork");
96
+ expect(recordToListItem(rec).outcome).toBe("failed");
95
97
  });
96
98
 
97
- it("closedReason:closed + closedReason='parent-new' → 透传(SP-4 级联关闭场景)", () => {
99
+ it("outcome:closed + closedReason='cancelled' → 'cancelled'(取消优先于 error)", () => {
98
100
  const rec = makeRecord({
99
101
  status: "closed",
100
- closedReason: "parent-new" as ClosedReason,
102
+ closedReason: "cancelled",
103
+ error: "aborted",
101
104
  });
102
- expect(recordToListItem(rec).closedReason).toBe("parent-new");
105
+ expect(recordToListItem(rec).outcome).toBe("cancelled");
103
106
  });
104
107
 
105
- it("closedReasonrunning record(无 closedReason)→ undefined", () => {
108
+ it("outcomeclosed + gc 正常完成(存量形态,无 outcome 字段、无 error)→ 兑底派生 'completed'", () => {
109
+ const rec = makeRecord({ status: "closed", closedReason: "gc" });
110
+ expect(recordToListItem(rec).outcome).toBe("completed");
111
+ });
112
+
113
+ it("outcome:closed + gc + error(存量失败形态)→ 兑底派生 'failed'", () => {
114
+ const rec = makeRecord({ status: "closed", closedReason: "gc", error: "spawn EPIPE" });
115
+ expect(recordToListItem(rec).outcome).toBe("failed");
116
+ });
117
+
118
+ it("outcome:一等字段存在时直读透传(不重推导)", () => {
119
+ const rec = makeRecord({ status: "closed", outcome: "failed", closedReason: "gc" });
120
+ expect(recordToListItem(rec).outcome).toBe("failed");
121
+ });
122
+
123
+ it("outcome:running record → undefined(终态语义不适用活跃态)", () => {
106
124
  const rec = makeRecord({ status: "running" });
107
- expect(recordToListItem(rec).closedReason).toBeUndefined();
125
+ expect(recordToListItem(rec).outcome).toBeUndefined();
126
+ });
127
+
128
+ it("closedReason 退出对外 JSON:list item 不再携带(内部诊断字段),outcome 字段在位", () => {
129
+ const rec = makeRecord({ status: "closed", closedReason: "gc", error: "boom" });
130
+ const item = recordToListItem(rec);
131
+ expect("closedReason" in item).toBe(false);
132
+ const parsed = JSON.parse(JSON.stringify(item)) as Record<string, unknown>;
133
+ expect("closedReason" in parsed).toBe(false);
134
+ expect(parsed.outcome).toBe("failed");
135
+ // 旧字段保留(向后兼容)
136
+ expect(parsed.status).toBe("closed");
137
+ expect(parsed.state).toBe("ended");
138
+ expect(parsed.mode).toBe("background");
108
139
  });
109
140
  });
@@ -97,7 +97,7 @@ describe("resolveModel — explicit override failures throw (no silent fallback)
97
97
  const reg = makeRegistry([]);
98
98
  expect(() =>
99
99
  resolveModel(undefined, reg, { model: "x/nonexistent" }, ctxModel),
100
- ).toThrow(/not found in registry/);
100
+ ).toThrow(/is not a registry entry/);
101
101
  });
102
102
 
103
103
  it("paramOverride.model found but auth missing → throws auth-specific message", () => {
@@ -117,7 +117,7 @@ describe("resolveModel — explicit override failures throw (no silent fallback)
117
117
  undefined,
118
118
  ctxModel,
119
119
  ),
120
- ).toThrow(/not found in registry/);
120
+ ).toThrow(/is not a registry entry/);
121
121
  });
122
122
 
123
123
  it("no override, no agentConfig.model, no ctxModel → throws listing available", () => {
@@ -132,7 +132,7 @@ describe("resolveModel — explicit override failures throw (no silent fallback)
132
132
  const reg = makeRegistry([]);
133
133
  expect(() =>
134
134
  resolveModel(undefined, reg, { model: "no-slash" }, ctxModel),
135
- ).toThrow(/not found in registry/);
135
+ ).toThrow(/is not a registry entry/);
136
136
  });
137
137
  });
138
138
 
@@ -264,7 +264,7 @@ describe('resolveModel — strips ":thinkingLevel" suffix from model string (A)'
264
264
  // ":foo" 不是合法 thinking level,不剥离 → 查不到 → 抛 not found
265
265
  const m = makeModel({ id: "m1", provider: "p", reasoning: false });
266
266
  const reg = makeRegistry([m]);
267
- expect(() => resolveModel(undefined, reg, { model: "p/m1:foo" })).toThrow(/not found in registry/);
267
+ expect(() => resolveModel(undefined, reg, { model: "p/m1:foo" })).toThrow(/is not a registry entry/);
268
268
  });
269
269
 
270
270
  it('suffix-stripped resolve still respects explicit thinkingLevel param', () => {
@@ -291,7 +291,7 @@ describe("resolveModel — not-found error suggests similar models (B)", () => {
291
291
  } catch (e) {
292
292
  msg = (e as Error).message;
293
293
  }
294
- expect(msg).toMatch(/not found in registry/);
294
+ expect(msg).toMatch(/is not a registry entry/);
295
295
  expect(msg).toMatch(/deepseek-router\/ds-pro/); // 建议列表含正确拼写
296
296
  });
297
297
 
@@ -351,6 +351,58 @@ describe("availableThinkingLevels", () => {
351
351
  });
352
352
  });
353
353
 
354
+ // ============================================================
355
+ // [U1 ModelRef 全等裁决] 孪生守卫(D1 规则④,两路径共用)
356
+ // ============================================================
357
+
358
+ describe("resolveModel — U1 twin guard (D1 rule ④)", () => {
359
+ /** 含大小写孪生的 registry 快照(模拟 models-store 刷新后大小写两形态并存)。 */
360
+ function makeTwinRegistry(): ModelRegistryLike {
361
+ return makeRegistry([
362
+ makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" }),
363
+ makeModel({ id: "glm-5.3-flash", provider: "zai-coding-cn" }),
364
+ ]);
365
+ }
366
+
367
+ it("显式入参全等命中但 registry 含孪生 → 拒单(ambiguous,不代改输入)", () => {
368
+ expect(() =>
369
+ resolveModel(undefined, makeTwinRegistry(), { model: "zai-coding-cn/GLM-5.3-Flash" }, ctxModel),
370
+ ).toThrow(/ambiguous case variants/);
371
+ });
372
+
373
+ it("ctxModel 继承路径孪生守卫同等生效(D2 豁免的只是存在性复查)", () => {
374
+ const main = makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" });
375
+ expect(() => resolveModel(undefined, makeTwinRegistry(), undefined, main)).toThrow(
376
+ /ambiguous case variants/,
377
+ );
378
+ });
379
+
380
+ it("无孪生时 ctxModel 路径零误伤(引用透传不变,P-A2 放行路径)", () => {
381
+ const main = makeModel({ id: "main-model", provider: "main" });
382
+ const r = resolveModel(undefined, makeRegistry([]), undefined, main);
383
+ expect(r.model).toBe(main);
384
+ });
385
+
386
+ it("小写入参(存在 case variant)→ 首个建议为正确大写串并标注(S2 负面路径)", () => {
387
+ const reg = makeRegistry([makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" })]);
388
+ let msg = "";
389
+ try {
390
+ resolveModel(undefined, reg, { model: "zai-coding-cn/glm-5.3-flash" }, ctxModel);
391
+ } catch (e) {
392
+ msg = (e as Error).message;
393
+ }
394
+ expect(msg).toMatch(/is not a registry entry/);
395
+ expect(msg).toContain('zai-coding-cn/GLM-5.3-Flash ← case variant of "glm-5.3-flash"');
396
+ });
397
+
398
+ it("放行后回显与入参全等(L1 路径 model 字段保持 registry 原始大小写)", () => {
399
+ const m = makeModel({ id: "GLM-5.3-Flash", provider: "zai-coding-cn" });
400
+ const r = resolveModel(undefined, makeRegistry([m]), { model: "zai-coding-cn/GLM-5.3-Flash" }, ctxModel);
401
+ expect(r.model.id).toBe("GLM-5.3-Flash");
402
+ expect(r.model.provider).toBe("zai-coding-cn");
403
+ });
404
+ });
405
+
354
406
  // ============================================================
355
407
  // ModelConfigService: initModel(ctxModel) → resolveModel L3 plumb-through
356
408
  // changeset 主打路径的 runtime 契约保护(EA-4)。
@@ -1,13 +1,14 @@
1
1
  /**
2
- * BgNotifier flushPendingNotifications — deliverAs 契约(FR-3/AC-3)。
2
+ * BgNotifier flushPendingNotifications — 单通道 triggerTurn 契约(U2 / D5)。
3
3
  *
4
- * 修复:deliverAs 从 'followUp' 改为 'steer',确保 subagent 完成通知在主 agent
5
- * 处于 processing 状态(如轮询 loop)时也能立即抢占下一个 turn。
4
+ * 迁移史:deliverAs 从 'followUp' 改为 'steer'(FR-3/AC-3,commit d214d0d83)后,
5
+ * U2 courier 单通道化再收敛——steer / followUp / nextTurn 通道全部删除(nextTurn
6
+ * 唯一 drain 点在 session.prompt() 内,主 agent 长 streaming 场景下无限期滞留,
7
+ * 设计 D5 实测证伪),唯一发送形态 = sendCustomMessage({triggerTurn:true});busy
8
+ * 场景由 ledger(settled 边沿 + isIdle 二次复查)或内核 settled 订阅在空闲边沿驱动。
6
9
  *
7
- * 修复背景:commit d214d0d83 已验证 steer 能避免 'Agent is already processing';
8
- * workflow helpers.ts:151 已在同语义下用 steer。
9
- *
10
- * 测试方法:mock NotifierHost,捕获 sendMessage 调用参数,断言 deliverAs === 'steer'。
10
+ * 测试方法:mock NotifierHost,捕获 sendMessage 调用参数,断言 options 恰为
11
+ * { triggerTurn: true }(G4 字节锁定测试新锚:行为契约不变,机械迁移)。
11
12
  */
12
13
 
13
14
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -33,7 +34,7 @@ function makeMockHost(): NotifierHost & {
33
34
  };
34
35
  }
35
36
 
36
- describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
37
+ describe("BgNotifier.flushPendingNotifications — 单通道 triggerTurn 契约(U2/D5)", () => {
37
38
  let host: ReturnType<typeof makeMockHost>;
38
39
  let notifier: BgNotifier;
39
40
 
@@ -46,7 +47,7 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
46
47
  notifier.dispose();
47
48
  });
48
49
 
49
- it("flush 时调 sendMessage 的 options.deliverAs === 'steer'(FR-3/AC-3)", () => {
50
+ it("flush sendMessage 的 options 恰为 { triggerTurn: true }(无 deliverAs,D5 单通道)", () => {
50
51
  notifier.notify({
51
52
  id: "bg-test-1",
52
53
  status: "closed",
@@ -59,10 +60,10 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
59
60
  // hasRunningBackground=false → notify 立即 flush
60
61
  expect(host.sendMessageCalls).toHaveLength(1);
61
62
  const call = host.sendMessageCalls[0];
62
- expect(call.options).toMatchObject({ deliverAs: "steer" });
63
+ expect(call.options).toEqual({ triggerTurn: true });
63
64
  });
64
65
 
65
- it("flush 时 triggerTurn 也必须为 true(让父 agent 立即唤醒)", () => {
66
+ it("flush 时 triggerTurn 必须为 true(让父 agent 立即唤醒)", () => {
66
67
  notifier.notify({
67
68
  id: "bg-test-2",
68
69
  status: "closed",
@@ -73,10 +74,7 @@ describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
73
74
  });
74
75
 
75
76
  expect(host.sendMessageCalls).toHaveLength(1);
76
- expect(host.sendMessageCalls[0].options).toMatchObject({
77
- triggerTurn: true,
78
- deliverAs: "steer",
79
- });
77
+ expect(host.sendMessageCalls[0].options).toEqual({ triggerTurn: true });
80
78
  });
81
79
  });
82
80
 
@@ -119,12 +117,9 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
119
117
  host.isIdle.mockReturnValue(true);
120
118
  vi.advanceTimersByTime(100);
121
119
 
122
- // idle 后发送,deliverAs=steer + triggerTurn=true
120
+ // idle 后发送,单通道 {triggerTurn:true}(U2/D5:无 deliverAs)
123
121
  expect(host.sendMessageCalls).toHaveLength(1);
124
- expect(host.sendMessageCalls[0].options).toMatchObject({
125
- triggerTurn: true,
126
- deliverAs: "steer",
127
- });
122
+ expect(host.sendMessageCalls[0].options).toEqual({ triggerTurn: true });
128
123
  });
129
124
 
130
125
  it("主 agent 持续 busy 达退避上限后强制发送(防通知饿死)", () => {
@@ -334,8 +329,8 @@ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile
334
329
 
335
330
  it("[review 修复] gc-failed + patchFile 并存 → failed 文案优先(失败轮也会写 patchFile,patch 提示不可达)", () => {
336
331
  // 回归锚定:doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch,gc 失败 +
337
- // worktree 并存时 patchFile 有值。判定顺序必须与 deriveClosedDisplay / renderRecordLines
338
- // 同构(cancelledgc+error → patch/result),否则 LLM 被告知 completed 掩盖失败。
332
+ // worktree 并存时 patchFile 有值。[U3] 判定收口到单一 deriveOutcome(cancelled
333
+ // failed → patch/result),否则 LLM 被告知 completed 掩盖失败。
339
334
  notifier.notify({
340
335
  id: "sa-ptr-8", status: "closed", closedReason: "gc", agent: "w",
341
336
  error: "spawn EPIPE",
@@ -347,6 +342,52 @@ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile
347
342
  expect(sentContent()).toBe('Subagent "w" (sa-ptr-8) failed: spawn EPIPE');
348
343
  });
349
344
 
345
+ it("[U3][D6 显式取舍] parent-shutdown 合成关闭 + patchFile 并存 → failed 文案优先(patch 提示不可达)", () => {
346
+ // disposeAllRecords 合成 result 恒写 error:"closed due to ..." → outcome='failed';
347
+ // worktree 失败并存时仍不得展示 patch 提示(「failed 优先于 patchFile 提示」保真)。
348
+ notifier.notify({
349
+ id: "sa-u3-ps", status: "closed", closedReason: "parent-shutdown", agent: "w",
350
+ error: "closed due to parent-shutdown",
351
+ patchFile: "/tmp/patches/sa-u3-ps.patch",
352
+ startedAt: 1, endedAt: 2,
353
+ });
354
+
355
+ expect(sentContent()).toBe('Subagent "w" (sa-u3-ps) failed: closed due to parent-shutdown');
356
+ });
357
+
358
+ it("[U3] details payload 物化 outcome:closed 入参缺省时按 deriveOutcome 兑底填充", () => {
359
+ notifier.notify({
360
+ id: "sa-u3-mat", status: "closed", closedReason: "gc", agent: "w",
361
+ error: "boom",
362
+ startedAt: 1, endedAt: 2,
363
+ });
364
+
365
+ expect(host.sendMessageCalls).toHaveLength(1);
366
+ const msg = host.sendMessageCalls[0]!.message as { details?: { outcome?: string } };
367
+ expect(msg.details?.outcome).toBe("failed");
368
+ });
369
+
370
+ it("[U3] 显式 outcome 优先于 closedReason 兑底(一等字段直读)", () => {
371
+ notifier.notify({
372
+ id: "sa-u3-explicit", status: "closed", closedReason: "gc", agent: "w",
373
+ outcome: "cancelled",
374
+ startedAt: 1, endedAt: 2,
375
+ });
376
+
377
+ expect(sentContent()).toBe('Subagent "w" (sa-u3-explicit) cancelled.');
378
+ });
379
+
380
+ it("[U3] running(轮次通知)不物化 outcome(终态语义不适用活跃态)", () => {
381
+ notifier.notify({
382
+ id: "sa-u3-round", status: "running", agent: "w", round: 1, result: "r1",
383
+ startedAt: 1, endedAt: 2,
384
+ });
385
+
386
+ expect(host.sendMessageCalls).toHaveLength(1);
387
+ const msg = host.sendMessageCalls[0]!.message as { details?: { outcome?: string } };
388
+ expect(msg.details?.outcome).toBeUndefined();
389
+ });
390
+
350
391
  it("one-shot(sessionFile 未透传 → undefined)→ closed 通知与改造前逐字节一致(基线常量锚定)", () => {
351
392
  notifier.notify({
352
393
  id: "sa-ptr-7", status: "closed", agent: "worker", result: "done",
@@ -433,10 +474,7 @@ describe("BgNotifier — subscribeSettled 装配(must-fix #4 / D8 settled 边
433
474
  settledEdges[0]!();
434
475
 
435
476
  expect(host.sendMessageCalls).toHaveLength(1);
436
- expect(host.sendMessageCalls[0]!.options).toMatchObject({
437
- triggerTurn: true,
438
- deliverAs: "steer",
439
- });
477
+ expect(host.sendMessageCalls[0]!.options).toEqual({ triggerTurn: true });
440
478
  });
441
479
 
442
480
  it("dispose 后 settled 边沿不再触发发送(内核 disposed 拦截)", () => {