@zhushanwen/pi-subagent-workflow 8.4.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 (171) hide show
  1. package/package.json +22 -7
  2. package/relay/relay.mjs +390 -0
  3. package/skills/subagent-ext-config/SKILL.md +80 -0
  4. package/src/execution/__tests__/agent-registry.test.ts +110 -0
  5. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  6. package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
  7. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  8. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  9. package/src/execution/__tests__/execution-record.test.ts +237 -1
  10. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  11. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  12. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  13. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  14. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  15. package/src/execution/__tests__/list-fields.test.ts +45 -14
  16. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  17. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  18. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  19. package/src/execution/__tests__/output-collector.test.ts +299 -2
  20. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  21. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  22. package/src/execution/__tests__/relay-env.test.ts +42 -0
  23. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  24. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  25. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  26. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  27. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  28. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  29. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  30. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  31. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  32. package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
  33. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  34. package/src/execution/__tests__/tool-action.test.ts +92 -1
  35. package/src/execution/agent-registry.ts +16 -0
  36. package/src/execution/argv-mirror.ts +5 -1
  37. package/src/execution/concurrency-pool.ts +1 -1
  38. package/src/execution/config.ts +25 -2
  39. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  40. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  41. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  42. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  43. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  44. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  45. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  46. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  47. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  48. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  49. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  50. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  51. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  52. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  53. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  54. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  55. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  56. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  57. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  58. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  59. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  60. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  61. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  62. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  63. package/src/execution/engine/common/data-dir.ts +62 -0
  64. package/src/execution/engine/common/errors.ts +183 -0
  65. package/src/execution/engine/common/event-journal.ts +254 -0
  66. package/src/execution/engine/common/journal-replay.ts +62 -0
  67. package/src/execution/engine/common/kill-chain.ts +221 -0
  68. package/src/execution/engine/common/nesting-guard.ts +50 -0
  69. package/src/execution/engine/common/persona-router.ts +108 -0
  70. package/src/execution/engine/common/pool-manager.ts +226 -0
  71. package/src/execution/engine/common/schema-emulation.ts +189 -0
  72. package/src/execution/engine/common/session-view-projection.ts +51 -0
  73. package/src/execution/engine/engine-discovery.ts +65 -0
  74. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  75. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  76. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  77. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  78. package/src/execution/engine/engines/pi/reader.ts +48 -0
  79. package/src/execution/engine/engines/pi/registration.ts +35 -0
  80. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  81. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  82. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  83. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  84. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  85. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  86. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  87. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  88. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
  89. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  90. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  91. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  92. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  93. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  94. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  95. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  96. package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
  97. package/src/execution/engine/host-task-spec.ts +47 -0
  98. package/src/execution/engine/model-prompt.ts +59 -0
  99. package/src/execution/engine/paths.ts +42 -0
  100. package/src/execution/engine/port.ts +153 -0
  101. package/src/execution/engine/registry.ts +123 -0
  102. package/src/execution/engine/routing.ts +218 -0
  103. package/src/execution/engine/types.ts +309 -0
  104. package/src/execution/execute-options-mapper.ts +13 -8
  105. package/src/execution/execution-record.ts +66 -1
  106. package/src/execution/lifecycle-manager.ts +23 -1
  107. package/src/execution/model-config-service.ts +16 -1
  108. package/src/execution/model-resolver.ts +37 -59
  109. package/src/execution/notifier.ts +105 -35
  110. package/src/execution/notify-ledger.ts +580 -0
  111. package/src/execution/output-collector.ts +143 -3
  112. package/src/execution/pi-invocation.ts +32 -2
  113. package/src/execution/record-entry.ts +14 -0
  114. package/src/execution/record-store.ts +34 -0
  115. package/src/execution/relay-env.ts +37 -0
  116. package/src/execution/session-runner.ts +328 -71
  117. package/src/execution/stream-sink.ts +26 -0
  118. package/src/execution/subagent-service.ts +273 -13
  119. package/src/execution/subprocess-agent-runner.ts +210 -14
  120. package/src/execution/types.ts +124 -5
  121. package/src/execution/ui-request-queue.ts +14 -4
  122. package/src/index.ts +99 -1
  123. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  124. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  125. package/src/interface/bg-notify-render.ts +33 -12
  126. package/src/interface/helpers.ts +2 -2
  127. package/src/interface/subagent-actions.ts +29 -9
  128. package/src/interface/subagent-tool-schema.ts +156 -0
  129. package/src/interface/subagent-tool.ts +56 -119
  130. package/src/interface/subagents.ts +2 -2
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  133. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  135. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  136. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  137. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  138. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  139. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  140. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  141. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  142. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  143. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  144. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  145. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +22 -3
  146. package/src/orchestration/agent-opts-resolver.ts +104 -23
  147. package/src/orchestration/error-recovery.ts +189 -33
  148. package/src/orchestration/execute-agent-call.ts +39 -0
  149. package/src/orchestration/jsonl-run-store.ts +121 -7
  150. package/src/orchestration/launcher.ts +60 -15
  151. package/src/orchestration/lifecycle.ts +10 -7
  152. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  153. package/src/orchestration/models/budget.ts +5 -35
  154. package/src/orchestration/models/run-runtime.ts +24 -9
  155. package/src/orchestration/models/types.ts +16 -0
  156. package/src/orchestration/script-lint.ts +1 -1
  157. package/src/orchestration/skill-discovery.ts +31 -8
  158. package/src/orchestration/worker-script-builder.ts +19 -3
  159. package/src/shared/__tests__/model-ref.test.ts +306 -0
  160. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  161. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  162. package/src/shared/meta-parser.ts +5 -1
  163. package/src/shared/model-ref.ts +286 -0
  164. package/src/shared/resource-meta.ts +5 -0
  165. package/src/shared/schema-env.ts +44 -0
  166. package/src/shared/schema-jsonify.ts +6 -4
  167. package/src/shared/timer-delay.ts +54 -0
  168. package/workflows/review-fix-loop-utils.cjs +9 -7
  169. package/workflows/review-fix-loop.js +20 -12
  170. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  171. package/src/orchestration/concurrency-gate.ts +0 -69
@@ -0,0 +1,826 @@
1
+ // src/execution/__tests__/notify-ledger.test.ts
2
+ //
3
+ // U2 B-ledger 单测族:通知账本与 courier 的四步生命周期
4
+ // (设计 docs/design/subagent-dispatch-reliability.md §3.3 D4/D5)。
5
+ //
6
+ // 覆盖验收面:
7
+ // - 写账先于投递(顺序断言:appendEntry ledger entry 先于 sendMessage)
8
+ // - settled 边沿投递 + isIdle 二次复查命中 busy 时挂回 pending
9
+ // - 回执销账(ack entry 追加)后两列 entry 差集为空
10
+ // - 重启恢复(新实例扫账本)重放未销账号、已销账零重发
11
+ // - notifyId 幂等(重复 record / 重复投递条目可识别)
12
+ // - 120s 看门狗触发(fake timers;主 session 长期无 settled 兜底 + 超时重投)
13
+ // - 合并投递(同一边沿多条 pending 单条送达,content join + batch details)
14
+ // - compaction 降级(ledger/ack entry 被清除后按内存态补写)
15
+ // - session_start 恢复钩子(bindNotifyLedgerHost + recoverFromSession:
16
+ // mock session entries 含 ledger/ack entry → 重放差集)
17
+ // - notifier 四步接线(createNotifier + bindNotifyLedgerHost:notify → 写账 →
18
+ // 边沿投递 → 回执销账全链路)
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+
22
+ import { clearRateLimiterState, setPiHandle } from "@zhushanwen/pi-extension-logger";
23
+ import {
24
+ bindNotifyLedgerHost,
25
+ createNotifyLedger,
26
+ getBoundNotifyLedger,
27
+ NOTIFY_ACK_CUSTOM_TYPE,
28
+ NOTIFY_CUSTOM_TYPE,
29
+ NOTIFY_LEDGER_CUSTOM_TYPE,
30
+ NOTIFY_WATCHDOG_MS,
31
+ _resetNotifyLedgerForTest,
32
+ type NotifyLedgerHost,
33
+ } from "../notify-ledger.ts";
34
+ import { createNotifier, type BgNotifyRecord, type NotifierHost } from "../notifier.ts";
35
+
36
+ // ─── mock host ─────────────────────────────────────────────
37
+
38
+ interface LedgerHostMock {
39
+ host: NotifyLedgerHost;
40
+ /** 追加的 plain custom entry(ledger/ack 两列,含 customType)。与 sessionEntries
41
+ * 共享同一数组——送达 custom_message entry 也会落入(无 data 字段)。 */
42
+ entries: { type: string; customType: string; data?: Record<string, unknown> }[];
43
+ /** session entries 视图(回执扫描 + 恢复扫描的输入;与 entries 共享同一数组)。 */
44
+ sessionEntries: unknown[];
45
+ /** 送达消息(sendDelivery 调用)。 */
46
+ sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[];
47
+ /** settled 边沿回调(测试手动触发模拟 pi agent_settled 事件)。 */
48
+ settledHandlers: Array<() => void>;
49
+ setIdle(idle: boolean): void;
50
+ /** 送达是否同步落盘为 custom_message entry(模拟 pi:triggerTurn run 结束后落盘)。
51
+ * false = 模拟回执延迟/丢失(消息滞留内存)。 */
52
+ deliverPersists: { value: boolean };
53
+ /** U4:sendDelivery 是否抛异常(模拟受理被拒——下方 sendDelivery 首行 throw)。 */
54
+ deliveryThrows: { value: boolean };
55
+ }
56
+
57
+ function makeLedgerHost(): LedgerHostMock {
58
+ const entries: LedgerHostMock["entries"] = [];
59
+ const sessionEntries: unknown[] = entries;
60
+ const sentMessages: LedgerHostMock["sentMessages"] = [];
61
+ const settledHandlers: Array<() => void> = [];
62
+ const idle = { value: true };
63
+ const deliverPersists = { value: true };
64
+ const deliveryThrows = { value: false };
65
+ const host: NotifyLedgerHost = {
66
+ appendLedgerEntry: (customType, data) => {
67
+ entries.push({ type: "custom", customType, data: data as Record<string, unknown> | undefined });
68
+ },
69
+ readSessionEntries: () => sessionEntries,
70
+ isIdle: () => idle.value,
71
+ onAgentSettled: (handler) => {
72
+ settledHandlers.push(handler);
73
+ },
74
+ sendDelivery: (message) => {
75
+ if (deliveryThrows.value) {
76
+ throw new Error("delivery rejected (mock)");
77
+ }
78
+ sentMessages.push(message);
79
+ if (deliverPersists.value) {
80
+ sessionEntries.push({
81
+ type: "custom_message",
82
+ customType: message.customType,
83
+ content: message.content,
84
+ display: message.display,
85
+ details: message.details,
86
+ });
87
+ }
88
+ },
89
+ };
90
+ return {
91
+ host,
92
+ entries,
93
+ sessionEntries,
94
+ sentMessages,
95
+ settledHandlers,
96
+ setIdle: (v: boolean) => {
97
+ idle.value = v;
98
+ },
99
+ deliverPersists,
100
+ deliveryThrows,
101
+ };
102
+ }
103
+
104
+ /** 触发一次 settled 边沿(模拟 pi agent_settled 事件)。 */
105
+ function fireSettled(mock: LedgerHostMock): void {
106
+ for (const handler of mock.settledHandlers) handler();
107
+ }
108
+
109
+ /** 两列 entry 差集(未销账号):ledger entry 的 notifyId − ack entry 的 notifyId。 */
110
+ function ledgerDiff(mock: LedgerHostMock): Set<string> {
111
+ const ledgerIds = new Set<string>();
112
+ const acked = new Set<string>();
113
+ for (const e of mock.entries) {
114
+ if (e.customType !== NOTIFY_LEDGER_CUSTOM_TYPE && e.customType !== NOTIFY_ACK_CUSTOM_TYPE) continue;
115
+ const notifyId = e.data?.["notifyId"];
116
+ if (typeof notifyId !== "string") continue;
117
+ if (e.customType === NOTIFY_LEDGER_CUSTOM_TYPE) ledgerIds.add(notifyId);
118
+ if (e.customType === NOTIFY_ACK_CUSTOM_TYPE) acked.add(notifyId);
119
+ }
120
+ for (const id of acked) ledgerIds.delete(id);
121
+ return ledgerIds;
122
+ }
123
+
124
+ // ─── 账本四步生命周期 ─────────────────────────────────────
125
+
126
+ describe("NotifyLedger — 四步生命周期(D4/D5)", () => {
127
+ beforeEach(() => {
128
+ _resetNotifyLedgerForTest();
129
+ });
130
+ afterEach(() => {
131
+ _resetNotifyLedgerForTest();
132
+ });
133
+
134
+ it("①→② 写账先于投递:appendEntry(ledger) 先于 sendDelivery(顺序断言)", () => {
135
+ const mock = makeLedgerHost();
136
+ const ledger = createNotifyLedger(mock.host);
137
+
138
+ const recorded = ledger.record("sa-1", "content-1", { notifyId: "sa-1", id: "sa-1" });
139
+ expect(recorded).toBe(true);
140
+ // idle → notify 后立即投递(record 内不自投,attemptDeliver 由调用方驱动;
141
+ // 此处显式调用验证顺序)
142
+ ledger.attemptDeliver();
143
+
144
+ expect(mock.sentMessages).toHaveLength(1);
145
+ // 顺序断言:账本 entry(entries[0])先于送达消息(sentMessages[0])
146
+ expect(mock.entries[0]?.customType).toBe(NOTIFY_LEDGER_CUSTOM_TYPE);
147
+ expect(mock.sentMessages[0]?.customType).toBe(NOTIFY_CUSTOM_TYPE);
148
+ // 单条投递形态:details = record 本体(notifyId 携带在 details,不在文案)
149
+ expect(mock.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-1" });
150
+
151
+ ledger.dispose();
152
+ });
153
+
154
+ it("② settled 边沿投递:busy 挂回 pending,isIdle 复查通过后边沿送达", () => {
155
+ const mock = makeLedgerHost();
156
+ const ledger = createNotifyLedger(mock.host);
157
+
158
+ // busy:notify 后 attemptDeliver 放弃本次,消息挂回 pending
159
+ mock.setIdle(false);
160
+ expect(ledger.record("sa-busy", "content", { notifyId: "sa-busy" })).toBe(true);
161
+ ledger.attemptDeliver();
162
+ expect(mock.sentMessages).toHaveLength(0);
163
+ expect(ledger.pendingCount()).toBe(1);
164
+
165
+ // settled 边沿(边沿回调内 isIdle 复查):仍 busy → 继续挂 pending
166
+ fireSettled(mock);
167
+ expect(mock.sentMessages).toHaveLength(0);
168
+ expect(ledger.pendingCount()).toBe(1);
169
+
170
+ // 主 agent 空闲后的下一 settled 边沿 → 送达
171
+ mock.setIdle(true);
172
+ fireSettled(mock);
173
+ expect(mock.sentMessages).toHaveLength(1);
174
+ expect(ledger.pendingCount()).toBe(0);
175
+ expect(ledger.waitingReceiptCount()).toBe(1);
176
+
177
+ ledger.dispose();
178
+ });
179
+
180
+ it("②→③ 回执销账:送达 entry 出现后下一 settled 边沿追加 ack entry,差集为空", () => {
181
+ const mock = makeLedgerHost();
182
+ const ledger = createNotifyLedger(mock.host);
183
+
184
+ ledger.record("sa-ack", "content", { notifyId: "sa-ack" });
185
+ ledger.attemptDeliver();
186
+ expect(mock.sentMessages).toHaveLength(1);
187
+ expect(ledgerDiff(mock)).toEqual(new Set(["sa-ack"])); // 已送达未销账
188
+
189
+ // 下一 settled 边沿:checkReceipts 扫到 custom_message entry(sendDelivery 已
190
+ // 模拟落盘)→ 追加 ack entry → 两列差集为空
191
+ fireSettled(mock);
192
+ expect(ledgerDiff(mock)).toEqual(new Set());
193
+ expect(ledger.waitingReceiptCount()).toBe(0);
194
+ expect(mock.entries.some((e) => e.customType === NOTIFY_ACK_CUSTOM_TYPE)).toBe(true);
195
+
196
+ ledger.dispose();
197
+ });
198
+
199
+ it("③ 内存不承担销账职责:回执缺失(消息滞留内存)时不销账,差集保留", () => {
200
+ const mock = makeLedgerHost();
201
+ const ledger = createNotifyLedger(mock.host);
202
+
203
+ // 模拟消息滞留内存(如竞态窗口掉进 steer 队列):sendDelivery 不落盘
204
+ mock.deliverPersists.value = false;
205
+ ledger.record("sa-lost", "content", { notifyId: "sa-lost" });
206
+ ledger.attemptDeliver();
207
+ expect(mock.sentMessages).toHaveLength(1);
208
+
209
+ fireSettled(mock);
210
+ // 无 custom_message entry 回执 → 不销账(内存不销账,等看门狗重投)
211
+ expect(ledgerDiff(mock)).toEqual(new Set(["sa-lost"]));
212
+ expect(ledger.waitingReceiptCount()).toBe(1);
213
+
214
+ ledger.dispose();
215
+ });
216
+
217
+ it("④ 重启恢复:新实例扫两列差集,未销账号重放、已销账零重发", () => {
218
+ // 旧实例(模拟崩溃前的进程):sa-r1/sa-r2 送达销账,sa-r3 入账未投
219
+ const oldMock = makeLedgerHost();
220
+ const oldLedger = createNotifyLedger(oldMock.host);
221
+ for (const id of ["sa-r1", "sa-r2"]) {
222
+ oldLedger.record(id, `content-${id}`, { notifyId: id });
223
+ }
224
+ oldLedger.attemptDeliver();
225
+ fireSettled(oldMock);
226
+ oldLedger.record("sa-r3", "content-sa-r3", { notifyId: "sa-r3" }); // 崩溃前未投
227
+ expect(ledgerDiff(oldMock)).toEqual(new Set(["sa-r3"]));
228
+ oldLedger.dispose();
229
+
230
+ // 重启:新实例扫同一 session 文件(sessionEntries 视图保留旧 entry 列)
231
+ const newMock = makeLedgerHost();
232
+ newMock.sessionEntries.push(...oldMock.sessionEntries);
233
+ const newLedger = createNotifyLedger(newMock.host);
234
+ const replayed = newLedger.recoverFromSession();
235
+
236
+ expect(replayed).toBe(1); // 仅未销账的 sa-r3 重放
237
+ expect(newMock.sentMessages).toHaveLength(1);
238
+ expect(newMock.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-r3" });
239
+ // 已销账号零重发
240
+ const sentIds = newMock.sentMessages.map((m) => (m.details as { notifyId?: string }).notifyId);
241
+ expect(sentIds).not.toContain("sa-r1");
242
+ expect(sentIds).not.toContain("sa-r2");
243
+
244
+ newLedger.dispose();
245
+ });
246
+
247
+ it("④ notifyId 幂等:重复 record 同 notifyId 返回 false,不重复投递", () => {
248
+ const mock = makeLedgerHost();
249
+ const ledger = createNotifyLedger(mock.host);
250
+
251
+ expect(ledger.record("sa-dup", "content", { notifyId: "sa-dup" })).toBe(true);
252
+ expect(ledger.record("sa-dup", "content", { notifyId: "sa-dup" })).toBe(false);
253
+
254
+ ledger.attemptDeliver();
255
+ ledger.attemptDeliver();
256
+ expect(mock.sentMessages).toHaveLength(1); // 至多一次
257
+
258
+ // 销账后同 notifyId 再来(重复调用方)→ 仍幂等拒绝(已销账号绝不重发)
259
+ fireSettled(mock);
260
+ expect(ledger.record("sa-dup", "content", { notifyId: "sa-dup" })).toBe(false);
261
+
262
+ ledger.dispose();
263
+ });
264
+
265
+ it("④ 重复投递条目可识别:批量送达 details.items 每项携带 notifyId(回执匹配)", () => {
266
+ const mock = makeLedgerHost();
267
+ const ledger = createNotifyLedger(mock.host);
268
+
269
+ ledger.record("sa-m1", "c1", { notifyId: "sa-m1" });
270
+ ledger.record("sa-m2", "c2", { notifyId: "sa-m2" });
271
+ ledger.attemptDeliver();
272
+
273
+ expect(mock.sentMessages).toHaveLength(1); // 合并为一条
274
+ const msg = mock.sentMessages[0]!;
275
+ expect(msg.content).toBe("c1\n\n---\n\nc2");
276
+ const details = msg.details as { batch: boolean; items: Array<{ notifyId?: string }> };
277
+ expect(details.batch).toBe(true);
278
+ expect(details.items.map((i) => i.notifyId)).toEqual(["sa-m1", "sa-m2"]);
279
+
280
+ // 批量回执匹配:items[].notifyId 命中 → 两条全部销账
281
+ fireSettled(mock);
282
+ expect(ledgerDiff(mock)).toEqual(new Set());
283
+
284
+ ledger.dispose();
285
+ });
286
+ });
287
+
288
+ // ─── 120s 看门狗(fake timers) ───────────────────────────
289
+
290
+ describe("NotifyLedger — 120s 看门狗(D5 ②兜底触发面)", () => {
291
+ beforeEach(() => {
292
+ vi.useFakeTimers();
293
+ _resetNotifyLedgerForTest();
294
+ });
295
+ afterEach(() => {
296
+ _resetNotifyLedgerForTest();
297
+ vi.useRealTimers();
298
+ });
299
+
300
+ it("主 session 长期无 settled:busy 挂起的 pending 由看门狗在空闲后补投", () => {
301
+ const mock = makeLedgerHost();
302
+ const ledger = createNotifyLedger(mock.host);
303
+
304
+ mock.setIdle(false);
305
+ ledger.record("sa-wd1", "content", { notifyId: "sa-wd1" });
306
+ ledger.attemptDeliver();
307
+ expect(mock.sentMessages).toHaveLength(0);
308
+
309
+ // 第一个周期仍 busy → 不投
310
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
311
+ expect(mock.sentMessages).toHaveLength(0);
312
+
313
+ // 主 agent 空闲(长期无 settled 事件)→ 下一周期看门狗补投
314
+ mock.setIdle(true);
315
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
316
+ expect(mock.sentMessages).toHaveLength(1);
317
+
318
+ ledger.dispose();
319
+ });
320
+
321
+ it("超时重投:sent 后一个周期无回执(消息滞留内存丢失)→ 看门狗重投", () => {
322
+ const mock = makeLedgerHost();
323
+ const ledger = createNotifyLedger(mock.host);
324
+
325
+ // 投递但不落盘(模拟 steer 滞留后丢失——回执永不到)
326
+ mock.deliverPersists.value = false;
327
+ ledger.record("sa-wd2", "content", { notifyId: "sa-wd2" });
328
+ ledger.attemptDeliver();
329
+ expect(mock.sentMessages).toHaveLength(1);
330
+
331
+ // 一个周期后:无回执 → sentAt 超时转回 pending → 本 tick 重投(at-least-once)
332
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
333
+ expect(mock.sentMessages).toHaveLength(2);
334
+ expect(mock.sentMessages[1]?.details).toMatchObject({ notifyId: "sa-wd2" }); // 同 notifyId 可识别
335
+
336
+ ledger.dispose();
337
+ });
338
+
339
+ it("回执正常时序不重投:送达落盘 + settled 销账后,看门狗空转零动作", () => {
340
+ const mock = makeLedgerHost();
341
+ const ledger = createNotifyLedger(mock.host);
342
+
343
+ ledger.record("sa-wd3", "content", { notifyId: "sa-wd3" });
344
+ ledger.attemptDeliver();
345
+ fireSettled(mock); // 销账
346
+ expect(ledgerDiff(mock)).toEqual(new Set());
347
+
348
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS * 3);
349
+ expect(mock.sentMessages).toHaveLength(1); // 无重投
350
+ expect(ledgerDiff(mock)).toEqual(new Set()); // 差集保持为空
351
+
352
+ ledger.dispose();
353
+ });
354
+ });
355
+
356
+ // ─── U4 投递计数分桶(设计 §5 U4 × §2.2 三条丢失路径) ─────
357
+
358
+ describe("NotifyLedger — U4 投递计数分桶", () => {
359
+ beforeEach(() => {
360
+ _resetNotifyLedgerForTest();
361
+ clearRateLimiterState();
362
+ });
363
+ afterEach(() => {
364
+ _resetNotifyLedgerForTest();
365
+ setPiHandle(undefined);
366
+ clearRateLimiterState();
367
+ });
368
+
369
+ it("初始快照:三桶全 0(无投递异常时零计数)", () => {
370
+ const mock = makeLedgerHost();
371
+ const ledger = createNotifyLedger(mock.host);
372
+ expect(ledger.deliveryMetrics()).toEqual({ settleRejected: 0, watchdogReplays: 0, recoveryReplays: 0 });
373
+ ledger.dispose();
374
+ });
375
+
376
+ it("②settleRejected 桶:sendDelivery 受理被拒 → +1 且消息挂回 pending;恢复后不再累计", () => {
377
+ const mock = makeLedgerHost();
378
+ const ledger = createNotifyLedger(mock.host);
379
+
380
+ mock.deliveryThrows.value = true;
381
+ ledger.record("sa-rj", "content", { notifyId: "sa-rj" });
382
+ ledger.attemptDeliver();
383
+
384
+ expect(ledger.deliveryMetrics().settleRejected).toBe(1);
385
+ expect(mock.sentMessages).toHaveLength(0);
386
+ expect(ledger.pendingCount()).toBe(1); // 挂回 pending,账未丢
387
+
388
+ // 下一边沿受理恢复:投递成功,rejection 桶不再增长
389
+ mock.deliveryThrows.value = false;
390
+ ledger.attemptDeliver();
391
+ expect(mock.sentMessages).toHaveLength(1);
392
+ expect(ledger.deliveryMetrics().settleRejected).toBe(1);
393
+
394
+ ledger.dispose();
395
+ });
396
+
397
+ it("①watchdogReplays 桶:销账超时重投按条数累计(同消息反复超时继续累计)", () => {
398
+ vi.useFakeTimers();
399
+ try {
400
+ const mock = makeLedgerHost();
401
+ const ledger = createNotifyLedger(mock.host);
402
+
403
+ // 投递但回执永不到(消息滞留内存丢失)
404
+ mock.deliverPersists.value = false;
405
+ ledger.record("sa-wd", "content", { notifyId: "sa-wd" });
406
+ ledger.attemptDeliver();
407
+ expect(ledger.deliveryMetrics().watchdogReplays).toBe(0);
408
+
409
+ // 第一个周期:销账超时 → 转回 pending 重投,桶 +1
410
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
411
+ expect(mock.sentMessages).toHaveLength(2);
412
+ expect(ledger.deliveryMetrics().watchdogReplays).toBe(1);
413
+
414
+ // 第二个周期仍无回执:同消息再重投,桶累计到 2(滞留深度可见)
415
+ vi.advanceTimersByTime(NOTIFY_WATCHDOG_MS);
416
+ expect(mock.sentMessages).toHaveLength(3);
417
+ expect(ledger.deliveryMetrics().watchdogReplays).toBe(2);
418
+
419
+ ledger.dispose();
420
+ } finally {
421
+ vi.useRealTimers();
422
+ }
423
+ });
424
+
425
+ it("③recoveryReplays 桶:session_start 恢复重放条数与返回值一致,已销账零计数", () => {
426
+ const mock = makeLedgerHost();
427
+ mock.sessionEntries.push(
428
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a", content: "ca", record: { notifyId: "s-a" } } },
429
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-b", content: "cb", record: { notifyId: "s-b" } } },
430
+ { type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a" } },
431
+ );
432
+
433
+ const ledger = createNotifyLedger(mock.host);
434
+ const replayed = ledger.recoverFromSession();
435
+
436
+ expect(replayed).toBe(1); // s-b 未销账重放;s-a 已销账零重发
437
+ expect(ledger.deliveryMetrics().recoveryReplays).toBe(1);
438
+
439
+ // 再次恢复(幂等):差集已入账,零重发零计数
440
+ expect(ledger.recoverFromSession()).toBe(0);
441
+ expect(ledger.deliveryMetrics().recoveryReplays).toBe(1);
442
+
443
+ ledger.dispose();
444
+ });
445
+
446
+ it("分桶增量经 extensionLogger 通道落日志(appendEntry,msg 固定 key + 动态值在 data)", () => {
447
+ const appendEntry = vi.fn();
448
+ setPiHandle({ appendEntry });
449
+
450
+ const mock = makeLedgerHost();
451
+ const ledger = createNotifyLedger(mock.host);
452
+
453
+ mock.deliveryThrows.value = true;
454
+ ledger.record("sa-log", "content", { notifyId: "sa-log" });
455
+ ledger.attemptDeliver();
456
+
457
+ expect(appendEntry).toHaveBeenCalledTimes(1);
458
+ const [customType, data] = appendEntry.mock.calls[0] as unknown as [string, { level: string; message: string; data: Record<string, unknown> }];
459
+ expect(customType).toBe("subagents:log");
460
+ expect(data.level).toBe("warn");
461
+ expect(data.message).toContain("notify delivery bucket [settleRejected]");
462
+ expect(data.data).toEqual({ total: 1, pending: 1 });
463
+
464
+ ledger.dispose();
465
+ });
466
+ });
467
+
468
+ // ─── compaction 降级(P-B4) ───────────────────────────────
469
+
470
+ describe("NotifyLedger — compaction 降级(P-B4 未验证,条件补写)", () => {
471
+ beforeEach(() => {
472
+ _resetNotifyLedgerForTest();
473
+ });
474
+ afterEach(() => {
475
+ _resetNotifyLedgerForTest();
476
+ });
477
+
478
+ it("compaction 清除 ledger/ack entry 后按内存态补写(重建两列差集)", () => {
479
+ const mock = makeLedgerHost();
480
+ const ledger = createNotifyLedger(mock.host);
481
+
482
+ // sa-c1 送达销账、sa-c2 入账未投
483
+ ledger.record("sa-c1", "c1", { notifyId: "sa-c1" });
484
+ ledger.attemptDeliver();
485
+ fireSettled(mock);
486
+ ledger.record("sa-c2", "c2", { notifyId: "sa-c2" });
487
+ expect(ledgerDiff(mock)).toEqual(new Set(["sa-c2"]));
488
+
489
+ // 模拟 compaction 清掉全部 plain custom entry(ledger/ack 两列全丢,送达
490
+ // custom_message entry 假设保留——保守验证降级闭环)
491
+ mock.sessionEntries.length = 0;
492
+
493
+ const rewritten = ledger.compactionCheck();
494
+ expect(rewritten).toBe(2); // 补写 sa-c2 的 ledger + sa-c1 的 ack
495
+ // 补写后差集恢复:仍只有 sa-c2 未销账(sa-c1 销账事实随 ack 补写恢复)
496
+ expect(ledgerDiff(mock)).toEqual(new Set(["sa-c2"]));
497
+
498
+ ledger.dispose();
499
+ });
500
+
501
+ it("entry 存活时 compactionCheck no-op(零补写)", () => {
502
+ const mock = makeLedgerHost();
503
+ const ledger = createNotifyLedger(mock.host);
504
+ ledger.record("sa-nc", "c", { notifyId: "sa-nc" });
505
+ ledger.attemptDeliver();
506
+ fireSettled(mock);
507
+
508
+ expect(ledger.compactionCheck()).toBe(0);
509
+ ledger.dispose();
510
+ });
511
+ });
512
+
513
+ // ─── session_start 恢复钩子(bindNotifyLedgerHost) ────────
514
+
515
+ describe("bindNotifyLedgerHost — session_start 恢复钩子", () => {
516
+ beforeEach(() => {
517
+ _resetNotifyLedgerForTest();
518
+ });
519
+ afterEach(() => {
520
+ _resetNotifyLedgerForTest();
521
+ });
522
+
523
+ it("bind + recoverFromSession:mock session 含 ledger/ack entry → 重放差集", () => {
524
+ const mock = makeLedgerHost();
525
+ // 预置 session 文件内容(模拟重启前的落盘):2 条 ledger + 1 条 ack + 1 条已送达
526
+ mock.sessionEntries.push(
527
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a", content: "ca", record: { notifyId: "s-a" } } },
528
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1, notifyId: "s-b", content: "cb", record: { notifyId: "s-b" } } },
529
+ { type: "custom", customType: NOTIFY_ACK_CUSTOM_TYPE, data: { v: 1, notifyId: "s-a" } },
530
+ { type: "custom_message", customType: NOTIFY_CUSTOM_TYPE, content: "ca", display: true, details: { notifyId: "s-a" } },
531
+ );
532
+
533
+ const ledger = bindNotifyLedgerHost(mock.host);
534
+ const replayed = ledger.recoverFromSession();
535
+
536
+ expect(replayed).toBe(1); // s-b 未销账重放;s-a 已销账零重发
537
+ expect(mock.sentMessages).toHaveLength(1);
538
+ expect(mock.sentMessages[0]?.content).toBe("cb");
539
+ expect(mock.sentMessages[0]?.details).toMatchObject({ notifyId: "s-b" });
540
+ });
541
+
542
+ it("重复 bind 替换旧实例(/resume /fork /new 的 session_start)", () => {
543
+ const mock1 = makeLedgerHost();
544
+ bindNotifyLedgerHost(mock1.host);
545
+ expect(bindNotifyLedgerHost(makeLedgerHost().host)).toBeDefined();
546
+ // 旧实例已 dispose(settled 回调静默)
547
+ fireSettled(mock1);
548
+ expect(mock1.sentMessages).toHaveLength(0);
549
+ });
550
+
551
+ it("损坏 / 形态不符 entry 跳过不 throw(无 notifyId / data 非 object)", () => {
552
+ const mock = makeLedgerHost();
553
+ mock.sessionEntries.push(
554
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE, data: { v: 1 } }, // 无 notifyId
555
+ { type: "custom", customType: NOTIFY_LEDGER_CUSTOM_TYPE }, // 无 data
556
+ { type: "custom", customType: "unrelated-type", data: { notifyId: "x" } },
557
+ null,
558
+ );
559
+ const ledger = bindNotifyLedgerHost(mock.host);
560
+ expect(ledger.recoverFromSession()).toBe(0);
561
+ });
562
+ });
563
+
564
+ // ─── notifier 四步接线(createNotifier × ledger) ─────────
565
+
566
+ describe("createNotifier — ledger 四步接线(U2)", () => {
567
+ beforeEach(() => {
568
+ _resetNotifyLedgerForTest();
569
+ });
570
+ afterEach(() => {
571
+ _resetNotifyLedgerForTest();
572
+ });
573
+
574
+ /** notifier 侧 mock host:sendMessage 是 ledger sendDelivery 的下游(index.ts
575
+ * 装配 sendDelivery = pi.sendMessage({triggerTurn:true}),此处直接捕获)。 */
576
+ function makeNotifierHost(): NotifierHost & {
577
+ sentOptions: unknown[];
578
+ sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[];
579
+ } {
580
+ const sentOptions: unknown[] = [];
581
+ const sentMessages: { customType: string; content: string; display: boolean; details?: unknown }[] = [];
582
+ return {
583
+ sentOptions,
584
+ sentMessages,
585
+ sendMessage(message, options) {
586
+ sentOptions.push(options);
587
+ sentMessages.push(message);
588
+ },
589
+ hasRunningBackground: () => false,
590
+ isIdle: () => true,
591
+ };
592
+ }
593
+
594
+ function oneShotRecord(id: string, result = "done"): BgNotifyRecord {
595
+ return { id, status: "closed", agent: "worker", result, startedAt: 1, endedAt:2 };
596
+ }
597
+
598
+ it("notify → 写账 → 投递 → 回执销账全链路(notifyId = id,G4 文案不变)", () => {
599
+ const ledgerMock = makeLedgerHost();
600
+ // sendDelivery 桥接到 notifier host.sendMessage(模拟 index.ts 装配)
601
+ const notifierHost = makeNotifierHost();
602
+ ledgerMock.host.sendDelivery = (message) => {
603
+ ledgerMock.sentMessages.push(message);
604
+ if (ledgerMock.deliverPersists.value) {
605
+ ledgerMock.sessionEntries.push({
606
+ type: "custom_message",
607
+ customType: message.customType,
608
+ content: message.content,
609
+ display: message.display,
610
+ details: message.details,
611
+ });
612
+ }
613
+ notifierHost.sendMessage(message, { triggerTurn: true });
614
+ };
615
+ bindNotifyLedgerHost(ledgerMock.host);
616
+
617
+ const notifier = createNotifier(notifierHost);
618
+ notifier.notify(oneShotRecord("sa-n1"));
619
+
620
+ // ① 写账先于 ② 投递:ledger entry 落盘且消息已送达
621
+ expect(ledgerMock.entries[0]?.customType).toBe(NOTIFY_LEDGER_CUSTOM_TYPE);
622
+ expect(notifierHost.sentMessages).toHaveLength(1);
623
+ // 单通道断言:options 只有 triggerTurn,无 deliverAs
624
+ expect(notifierHost.sentOptions[0]).toEqual({ triggerTurn: true });
625
+ // G4 文案锚:one-shot 完成通知逐字节不变(notifyId 不进文案)
626
+ expect(notifierHost.sentMessages[0]?.content).toBe('Subagent "worker" (sa-n1) completed. Result:\ndone');
627
+ // details 携带 notifyId + outcome 物化(U3)
628
+ expect(notifierHost.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-n1", outcome: "completed" });
629
+
630
+ // ③ 回执销账:settled 边沿 → ack entry + 差集为空
631
+ fireSettled(ledgerMock);
632
+ expect(ledgerDiff(ledgerMock)).toEqual(new Set());
633
+
634
+ notifier.dispose();
635
+ });
636
+
637
+ it("chatMode round dedupe key 平移:notifyId = `${id}:${round}`,规则不变", () => {
638
+ const ledgerMock = makeLedgerHost();
639
+ const notifierHost = makeNotifierHost();
640
+ // 桥接:ledger sendDelivery → notifier host.sendMessage(模拟 index.ts 装配)
641
+ ledgerMock.host.sendDelivery = (message) => {
642
+ notifierHost.sendMessage(message, { triggerTurn: true });
643
+ if (ledgerMock.deliverPersists.value) {
644
+ ledgerMock.sessionEntries.push({
645
+ type: "custom_message",
646
+ customType: message.customType,
647
+ content: message.content,
648
+ display: message.display,
649
+ details: message.details,
650
+ });
651
+ }
652
+ };
653
+ bindNotifyLedgerHost(ledgerMock.host);
654
+
655
+ const notifier = createNotifier(notifierHost);
656
+ notifier.notify({ id: "sa-chat", status: "running", agent: "w", round: 1, result: "r1", startedAt: 1, endedAt: 2 });
657
+ notifier.notify({ id: "sa-chat", status: "running", agent: "w", round: 2, result: "r2", startedAt: 3, endedAt: 4 });
658
+ // 同 id 同 round 第二次被 notifyId 幂等吞
659
+ notifier.notify({ id: "sa-chat", status: "running", agent: "w", round: 1, result: "r1-again", startedAt: 5, endedAt: 6 });
660
+
661
+ expect(notifierHost.sentMessages).toHaveLength(2);
662
+ expect(notifierHost.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-chat:1" });
663
+ expect(notifierHost.sentMessages[1]?.details).toMatchObject({ notifyId: "sa-chat:2" });
664
+ // chatMode 轮次通知文案(running 分支)不变
665
+ expect(notifierHost.sentMessages[0]?.content).toBe('Subagent "w" (sa-chat) finished a round. Reply:\nr1');
666
+
667
+ notifier.dispose();
668
+ });
669
+
670
+ it("ledger 未 bind 时退回内核路径(向后兼容,options 仍单通道 triggerTurn)", () => {
671
+ const notifierHost = makeNotifierHost();
672
+ const notifier = createNotifier(notifierHost);
673
+ notifier.notify(oneShotRecord("sa-nb"));
674
+
675
+ expect(notifierHost.sentMessages).toHaveLength(1);
676
+ expect(notifierHost.sentOptions[0]).toEqual({ triggerTurn: true });
677
+ expect(notifierHost.sentMessages[0]?.content).toBe('Subagent "worker" (sa-nb) completed. Result:\ndone');
678
+
679
+ notifier.dispose();
680
+ });
681
+
682
+ it("U4 warn 注入:内核路径 port.send 失败 → 警告落 extensionLogger appendEntry,console.warn 零调用", () => {
683
+ const appendEntry = vi.fn();
684
+ setPiHandle({ appendEntry });
685
+ const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
686
+ try {
687
+ const notifierHost = makeNotifierHost();
688
+ // sendMessage 抛异常 = port.send 同步失败 → 内核 onSendFail → warn(经注入出口)
689
+ notifierHost.sendMessage = () => {
690
+ throw new Error("channel closed (mock)");
691
+ };
692
+ const notifier = createNotifier(notifierHost);
693
+ notifier.notify(oneShotRecord("sa-warn"));
694
+
695
+ const logCalls = appendEntry.mock.calls.filter((c) => c[0] === "subagents:log");
696
+ expect(logCalls.length).toBeGreaterThanOrEqual(1);
697
+ // 按内容定位目标日志(logCalls[0] 可能是 ledger 未 bind 的 fallback warn——
698
+ // 该降级留痕同样落 extensionLogger 通道,先于 port.send 失败发生)
699
+ const sendFailLogged = logCalls.some((c) => JSON.stringify(c[1]).includes("port.send failed"));
700
+ expect(sendFailLogged).toBe(true);
701
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
702
+
703
+ notifier.dispose();
704
+ } finally {
705
+ consoleWarnSpy.mockRestore();
706
+ setPiHandle(undefined);
707
+ clearRateLimiterState();
708
+ }
709
+ });
710
+
711
+ it("dispose 摘除模块级绑定:后续 notify 走内核路径,settled 边沿静默", () => {
712
+ const ledgerMock = makeLedgerHost();
713
+ bindNotifyLedgerHost(ledgerMock.host);
714
+ const notifierHost = makeNotifierHost();
715
+ const notifier = createNotifier(notifierHost);
716
+ notifier.dispose();
717
+
718
+ fireSettled(ledgerMock);
719
+ // revive(/resume /fork /new 后):notifier 复活,但 ledger 已随 dispose 摘除
720
+ // ——notify 退回内核路径
721
+ notifier.revive();
722
+ notifier.notify(oneShotRecord("sa-post-dispose"));
723
+ expect(notifierHost.sentMessages).toHaveLength(1); // 内核路径立即投(无 ledger)
724
+ });
725
+ });
726
+
727
+ // ─── MF-5: settled 监听单例化 ──────────────────────────────
728
+ //
729
+ // 回归背景:index.ts:428-430 每次 session_start 注册新 pi.on("agent_settled")
730
+ // handler,旧实例 dispose 只置标志不移除物理监听(pi 0.84.1 on() 无 off)——
731
+ // 长会话 N 次切换累积 N 个死 handler。修复:bind 路径首次注册模块级单例
732
+ // handler(settledEdgeDispatch),后续 bind 只换 boundLedger 引用。
733
+
734
+ describe("MF-5: settled 监听单例化(多次 bind 物理监听数不增)", () => {
735
+ beforeEach(() => {
736
+ _resetNotifyLedgerForTest();
737
+ });
738
+ afterEach(() => {
739
+ _resetNotifyLedgerForTest();
740
+ });
741
+
742
+ it("连续 3 次 bind(/resume /fork /new 的 session_start)→ onAgentSettled 注册仅首次发生", () => {
743
+ const mocks = [makeLedgerHost(), makeLedgerHost(), makeLedgerHost()];
744
+ const registerSpies = mocks.map((mock) => {
745
+ const spy = vi.fn();
746
+ mock.host.onAgentSettled = spy;
747
+ return spy;
748
+ });
749
+
750
+ bindNotifyLedgerHost(mocks[0]!.host);
751
+ bindNotifyLedgerHost(mocks[1]!.host);
752
+ bindNotifyLedgerHost(mocks[2]!.host);
753
+
754
+ // 首次 bind 注册一次(单例 handler);后续 bind 只换 boundLedger 引用
755
+ expect(registerSpies[0]).toHaveBeenCalledTimes(1);
756
+ expect(registerSpies[1]).not.toHaveBeenCalled();
757
+ expect(registerSpies[2]).not.toHaveBeenCalled();
758
+ });
759
+
760
+ it("边沿分发到当前活跃实例:旧 mock 上的边沿事件驱动新 ledger 投递,旧实例零动作", () => {
761
+ const mock1 = makeLedgerHost();
762
+ bindNotifyLedgerHost(mock1.host);
763
+ const mock2 = makeLedgerHost();
764
+ bindNotifyLedgerHost(mock2.host);
765
+
766
+ // 活跃实例(mock2 的 ledger)入账 pending
767
+ const ledger = getBoundNotifyLedger();
768
+ expect(ledger).toBeDefined();
769
+ ledger!.record("sa-active", "content", { notifyId: "sa-active" });
770
+
771
+ // 旧 mock(mock1)上触发边沿——单例 handler 分发到 boundLedger(mock2)
772
+ fireSettled(mock1);
773
+ expect(mock2.sentMessages).toHaveLength(1);
774
+ expect(mock2.sentMessages[0]?.details).toMatchObject({ notifyId: "sa-active" });
775
+ // 旧实例无动作(disposed 短路语义:不会成为分发目标)
776
+ expect(mock1.sentMessages).toHaveLength(0);
777
+ });
778
+
779
+ it("活跃实例 dispose 后边沿静默(boundLedger 摘除 → 单例 handler 无分发目标)", () => {
780
+ const mock1 = makeLedgerHost();
781
+ bindNotifyLedgerHost(mock1.host);
782
+ getBoundNotifyLedger()!.dispose();
783
+
784
+ fireSettled(mock1);
785
+ expect(mock1.sentMessages).toHaveLength(0);
786
+ });
787
+
788
+ it("直调 createNotifyLedger 注册行为不变(默认注册 / 显式 registerSettledListener:false 跳过)", () => {
789
+ const mockDefault = makeLedgerHost();
790
+ const ledgerDefault = createNotifyLedger(mockDefault.host);
791
+ expect(mockDefault.settledHandlers).toHaveLength(1);
792
+ ledgerDefault.dispose();
793
+
794
+ const mockSkip = makeLedgerHost();
795
+ const ledgerSkip = createNotifyLedger(mockSkip.host, { registerSettledListener: false });
796
+ expect(mockSkip.settledHandlers).toHaveLength(0);
797
+ ledgerSkip.dispose();
798
+ });
799
+ });
800
+
801
+ // ─── 常量等值钉住(notify-ledger 与 notifier/渲染器共用字符串) ──
802
+
803
+ describe("notify-ledger 常量锚", () => {
804
+ it("NOTIFY_CUSTOM_TYPE 与 notifier 送达 customType / ledger 回执扫描同源", async () => {
805
+ const notifierModule = await import("../notifier.ts");
806
+ // notifier 模块不再自带本地常量(单一常量源 = notify-ledger);用运行时行为钉住:
807
+ // ledger 回执扫描匹配的 customType === notifier 送达消息的 customType。
808
+ const mock = makeLedgerHost();
809
+ const notifierHost: NotifierHost = {
810
+ sendMessage: (message) => {
811
+ mock.host.sendDelivery(message);
812
+ },
813
+ hasRunningBackground: () => false,
814
+ isIdle: () => true,
815
+ };
816
+ const ledger = bindNotifyLedgerHost(mock.host);
817
+ const notifier = createNotifier(notifierHost);
818
+ notifier.notify({ id: "sa-cst", status: "closed", agent: "w", result: "r", startedAt: 1, endedAt: 2 });
819
+ fireSettled(mock);
820
+ // 全链路销账成功 === 三处 customType 同一字符串(否则回执扫描不命中)
821
+ expect(ledgerDiff(mock)).toEqual(new Set());
822
+ expect(typeof notifierModule.createNotifier).toBe("function");
823
+ ledger.dispose();
824
+ _resetNotifyLedgerForTest();
825
+ });
826
+ });