@zhushanwen/pi-session-manager 0.1.15 → 0.2.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.
@@ -0,0 +1,563 @@
1
+ // watch-orchestration.test.ts — notify-once U4 extension 编排单测(mock select 响应)。
2
+ // 覆盖:send/create arm(register 三键 + 开表不传 timeout + willNotify 透传)/
3
+ // reason 类别分支 / deathSeq·settleSeq 分组去重(两层攒批模型)/ 两例外(cancelled·orphaned
4
+ // 静默 + 死亡新闻槽)/ record 幂等键与 deliveryCustomType / session_compact 接线。
5
+ //
6
+ // watch select 全部走 deferred:开表时挂起,用例按调用序精确控制应答时机与内容
7
+ // (覆盖「晚 respond」「W1 悬置 W2 应答」等时序形态)。
8
+ // pi/ctx 装配、respondWatch、flushMicrotasks 走共享 harness(helpers/extension-harness.ts);
9
+ // select 的 action 分发(actionResults / deferred watch)为本文件域行为,留在本地。
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
12
+ import { SESSION_MANAGER_MARKER } from "@zhushanwen/extension-protocol";
13
+ // logger mock helper 必须先于 extension-harness(其 import 链触发被 mock 模块加载)
14
+ import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
15
+ import {
16
+ flushMicrotasks,
17
+ makeRespondWatch,
18
+ mountExtension,
19
+ runToolCall,
20
+ runToolForText,
21
+ type ExtensionHarness,
22
+ type WatchInvocation,
23
+ } from "./helpers/extension-harness.ts";
24
+
25
+ vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
26
+
27
+ const loggerMock = getLoggerMock();
28
+
29
+ const LEDGER_SLOT_KEY = Symbol.for("@zhushanwen/pi-subagents.notifyLedger");
30
+
31
+ /** watch 开表调用(deferred)——opts 为开表时传入的 select 第三参原样值 */
32
+ interface DeferredWatch extends WatchInvocation {
33
+ opts: { timeout?: number } | undefined;
34
+ }
35
+
36
+ interface Harness extends ExtensionHarness {
37
+ selectMock: Mock;
38
+ entries: unknown[];
39
+ /** 非 watch action 的回包(JSON 字符串),用例在跑工具前置位 */
40
+ actionResults: Record<string, () => string>;
41
+ /** 按开表顺序排列的 watch 调用(deferred 应答入口) */
42
+ watches: DeferredWatch[];
43
+ /** 按调用序 resolve 第 n 次 watch(value = select 回包原样值) */
44
+ respondWatch(index: number, value: unknown): void;
45
+ }
46
+
47
+ function createHarness(entries: unknown[] = []): Harness {
48
+ const watches: DeferredWatch[] = [];
49
+ const actionResults: Record<string, () => string> = {};
50
+ const selectMock = vi.fn((marker: unknown, args: unknown[], opts?: unknown) => {
51
+ expect(marker).toBe(SESSION_MANAGER_MARKER);
52
+ const payload = JSON.parse((args as string[])[0]) as {
53
+ action: string;
54
+ params: Record<string, unknown>;
55
+ };
56
+ if (payload.action === "watch") {
57
+ return new Promise((resolve) => {
58
+ watches.push({
59
+ notifyId: String(payload.params.notifyId),
60
+ opts: opts as { timeout?: number } | undefined,
61
+ resolve,
62
+ });
63
+ });
64
+ }
65
+ const produce = actionResults[payload.action];
66
+ return Promise.resolve(produce ? produce() : '{"ok":true}');
67
+ });
68
+ return {
69
+ ...mountExtension(selectMock, entries),
70
+ selectMock,
71
+ entries,
72
+ actionResults,
73
+ watches,
74
+ respondWatch: makeRespondWatch(watches),
75
+ };
76
+ }
77
+
78
+ function emittedIds(h: Harness, event: string): string[] {
79
+ return h.emitted.filter((e) => e.event === event).map((e) => e.data.id as string);
80
+ }
81
+
82
+ function registersOf(h: Harness): Array<{ id: string; type: unknown; name: unknown }> {
83
+ return h.emitted
84
+ .filter((e) => e.event === "pending:register")
85
+ .map((e) => ({ id: String(e.data.id), type: e.data.type, name: e.data.name }));
86
+ }
87
+
88
+ const SEND_OK = '{"queued":true,"willNotify":true}';
89
+ const SEND_NO_NOTIFY = '{"queued":true,"willNotify":false}';
90
+
91
+ /** 植入 ledger 槽(notify-ledger 镜像的读取目标)——record spy 由用例自持 */
92
+ function plantLedger(
93
+ record: (id: string, content: string, details: object, options?: unknown) => boolean,
94
+ compactionCheck: () => number = () => 0,
95
+ ): void {
96
+ Reflect.set(globalThis, LEDGER_SLOT_KEY, { current: { record, compactionCheck } });
97
+ }
98
+
99
+ function recordSpy(): ReturnType<typeof vi.fn> {
100
+ const spy = vi.fn(() => true);
101
+ plantLedger(spy as never);
102
+ return spy;
103
+ }
104
+
105
+ /** 「兼容与防御」组共用前置:植入 record spy → arm 单笔 send → 应答首个 watch →
106
+ * 排干微任务 → 推进 50ms 攒批窗(三用例的时序骨架逐字同款) */
107
+ async function armSendAndRespondWatch(
108
+ respondValue: unknown,
109
+ ): Promise<{ record: ReturnType<typeof vi.fn>; h: Harness }> {
110
+ const record = recordSpy();
111
+ const h = createHarness();
112
+ h.actionResults.send = () => SEND_OK;
113
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "x" });
114
+ await flushMicrotasks();
115
+ h.respondWatch(0, respondValue);
116
+ await flushMicrotasks();
117
+ await vi.advanceTimersByTimeAsync(50);
118
+ return { record, h };
119
+ }
120
+
121
+ beforeEach(() => {
122
+ vi.useFakeTimers();
123
+ loggerMock.warn.mockClear();
124
+ });
125
+
126
+ afterEach(() => {
127
+ vi.useRealTimers();
128
+ Reflect.deleteProperty(globalThis, LEDGER_SLOT_KEY);
129
+ });
130
+
131
+ // ── arm:send/create 成功 → register + 开表 + willNotify 透传 ──────────────────
132
+
133
+ describe("arm(send/create → pending:register + watch 开表)", () => {
134
+ it("send willNotify:true → register 三键 {id=notifyId, type:'session', name} + 开表(watch 不传 timeout)", async () => {
135
+ const h = createHarness();
136
+ h.actionResults.send = () => SEND_OK;
137
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "go" });
138
+ await flushMicrotasks();
139
+
140
+ // 请求面:send params 携带 sm- 形态 notifyId
141
+ const sendPayload = JSON.parse(h.selectMock.mock.calls[0][1][0]) as {
142
+ action: string;
143
+ params: Record<string, unknown>;
144
+ };
145
+ expect(sendPayload.action).toBe("send");
146
+ expect(String(sendPayload.params.notifyId)).toMatch(/^sm-/);
147
+
148
+ // register:P4 三键契约(label 缓存空 → name 退化 sessionId)
149
+ const regs = registersOf(h);
150
+ expect(regs).toHaveLength(1);
151
+ expect(regs[0].id).toMatch(/^sm-/);
152
+ expect(regs[0]).toEqual({ id: regs[0].id, type: "session", name: "child-1" });
153
+
154
+ // 开表:同 notifyId、无 timeout(D2/P1 fire-and-forget)
155
+ expect(h.watches).toHaveLength(1);
156
+ expect(h.watches[0].notifyId).toBe(regs[0].id);
157
+ expect(h.watches[0].opts?.timeout).toBeUndefined();
158
+ });
159
+
160
+ it("工具结果原样透传:send result 含 willNotify 字段(LLM 契约面显式化)", async () => {
161
+ const h = createHarness();
162
+ h.actionResults.send = () => SEND_OK;
163
+ const result = await runToolForText(h, "send_to_session", {
164
+ sessionId: "child-1",
165
+ prompt: "go",
166
+ });
167
+ expect(Object.hasOwn(result, "isError")).toBe(false);
168
+ expect(JSON.parse(result.content[0].text)).toEqual({ queued: true, willNotify: true });
169
+ });
170
+
171
+ it("create 带 prompt → claim + lifetime 双 register 双开表;label 入 name 与缓存(随后 send 复用)", async () => {
172
+ const lifetime = "sm-11111111-2222-3333-4444-555555555555";
173
+ const h = createHarness();
174
+ h.actionResults.create = () =>
175
+ JSON.stringify({
176
+ sessionId: "child-9",
177
+ status: "created",
178
+ willNotify: true,
179
+ lifetimeNotifyId: lifetime,
180
+ });
181
+ h.actionResults.send = () => SEND_OK;
182
+ await runToolCall(h, "create_managed_session", {
183
+ cwd: "/w",
184
+ label: "auth-refactor",
185
+ prompt: "do it",
186
+ });
187
+ await flushMicrotasks();
188
+
189
+ const regs = registersOf(h);
190
+ expect(regs).toHaveLength(2);
191
+ expect(regs[0].id).toMatch(/^sm-/);
192
+ expect(regs[0]).toEqual({ id: regs[0].id, type: "session", name: "auth-refactor" });
193
+ expect(regs[1]).toEqual({ id: lifetime, type: "session", name: "auth-refactor" });
194
+ expect(h.watches.map((w) => w.notifyId)).toEqual([regs[0].id, lifetime]);
195
+
196
+ // label 缓存回填:随后 send 的 register name = 工具入参 label(D9 正常路径)
197
+ await runToolCall(h, "send_to_session", { sessionId: "child-9", prompt: "again" });
198
+ await flushMicrotasks();
199
+ const afterSend = registersOf(h);
200
+ expect(afterSend).toHaveLength(3);
201
+ expect(afterSend[2].name).toBe("auth-refactor");
202
+ expect(afterSend[2].id).toMatch(/^sm-/);
203
+ });
204
+
205
+ it("create 无 prompt → 请求不带 notifyId;willNotify:false → 零 claim register,仅 lifetime(D-12 核验:裁决1 只封 claim,lifetime watch 是裁决2 载体必须无条件开)", async () => {
206
+ const lifetime = "sm-11111111-2222-3333-4444-555555555555";
207
+ const h = createHarness();
208
+ h.actionResults.create = () =>
209
+ JSON.stringify({
210
+ sessionId: "child-9",
211
+ status: "created",
212
+ willNotify: false,
213
+ lifetimeNotifyId: lifetime,
214
+ });
215
+ await runToolCall(h, "create_managed_session", { cwd: "/w", label: "L" });
216
+ await flushMicrotasks();
217
+
218
+ const createPayload = JSON.parse(h.selectMock.mock.calls[0][1][0]) as {
219
+ params: Record<string, unknown>;
220
+ };
221
+ expect(createPayload.params.notifyId).toBeUndefined();
222
+ const regs = registersOf(h);
223
+ expect(regs).toHaveLength(1);
224
+ // lifetime register:type 'session' + name=label(label 缺省 → sessionId 回退前的入参)
225
+ expect(regs[0]).toEqual({ id: lifetime, type: "session", name: "L" });
226
+ // lifetime watch 无条件开(死亡通知载体)
227
+ expect(h.watches.map((w) => w.notifyId)).toEqual([lifetime]);
228
+ expect(h.watches[0].opts?.timeout).toBeUndefined();
229
+ });
230
+
231
+ it("willNotify:false 的 send(runtime 未 arm)→ 零 register 零开表", async () => {
232
+ const h = createHarness();
233
+ h.actionResults.send = () => SEND_NO_NOTIFY;
234
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "x" });
235
+ await flushMicrotasks();
236
+ expect(registersOf(h)).toHaveLength(0);
237
+ expect(h.watches).toHaveLength(0);
238
+ });
239
+
240
+ it("list 结果回填 label 缓存(进程重启后 send name 的兜底源)", async () => {
241
+ const h = createHarness();
242
+ h.actionResults.list = () =>
243
+ JSON.stringify({ sessions: [{ id: "c1", label: "restored-label" }], undeliveredResults: 0 });
244
+ h.actionResults.send = () => SEND_OK;
245
+ await runToolCall(h, "list_my_sessions", {});
246
+ await runToolCall(h, "send_to_session", { sessionId: "c1", prompt: "x" });
247
+ await flushMicrotasks();
248
+ const regs = registersOf(h);
249
+ expect(regs).toHaveLength(1);
250
+ expect(regs[0].name).toBe("restored-label");
251
+ });
252
+ });
253
+
254
+ // ── 默认 record + trailing debounce 两层攒批(settle 腿) ──────────────────────
255
+
256
+ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
257
+ async function armTwoSends(h: Harness): Promise<{ id1: string; id2: string }> {
258
+ h.actionResults.send = () => SEND_OK;
259
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "a" });
260
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "b" });
261
+ await flushMicrotasks();
262
+ const ids = emittedIds(h, "pending:register");
263
+ expect(ids).toHaveLength(2);
264
+ expect(h.watches).toHaveLength(2);
265
+ return { id1: ids[0], id2: ids[1] };
266
+ }
267
+
268
+ it("同 settleSeq 两笔 → 恰一条 record(fulfills 2)+ 逐笔 unregister;trailing 50ms 前不 flush(A6 合批)", async () => {
269
+ const record = recordSpy();
270
+ const h = createHarness();
271
+ const { id1, id2 } = await armTwoSends(h);
272
+
273
+ const respond = {
274
+ reason: "completed",
275
+ sessionId: "child-1",
276
+ settleSeq: 7,
277
+ fulfillsN: 2,
278
+ };
279
+ h.respondWatch(0, JSON.stringify(respond));
280
+ h.respondWatch(1, JSON.stringify(respond));
281
+ await flushMicrotasks();
282
+
283
+ // trailing debounce:应答到达但 50ms 窗未到 → 零 record 零 unregister
284
+ expect(record).not.toHaveBeenCalled();
285
+ expect(emittedIds(h, "pending:unregister")).toHaveLength(0);
286
+
287
+ await vi.advanceTimersByTimeAsync(50);
288
+ expect(record).toHaveBeenCalledTimes(1);
289
+ const [notifyId, content, details, options] = record.mock.calls[0] as [
290
+ string,
291
+ string,
292
+ Record<string, unknown>,
293
+ { deliveryCustomType: string },
294
+ ];
295
+ expect(notifyId).toBe(id1); // 批幂等键 = 批内首个响应 notifyId
296
+ expect(content).toBe(
297
+ 'Managed session "child-1" (child-1) finished with status "completed". (fulfills 2 requests)',
298
+ );
299
+ expect(details).toEqual({
300
+ notifyId: id1,
301
+ sessionId: "child-1",
302
+ reason: "completed",
303
+ fulfills: 2,
304
+ label: "child-1",
305
+ });
306
+ expect(options.deliveryCustomType).toBe("managed-session-notify");
307
+ expect(emittedIds(h, "pending:unregister").sort()).toEqual([id1, id2].sort());
308
+ expect(
309
+ h.emitted.filter((e) => e.event === "pending:unregister").map((e) => e.data.reason),
310
+ ).toEqual(["completed", "completed"]);
311
+ });
312
+
313
+ it("不同 settleSeq → 两条 record(A7:每轮一次,seq 边界排除跨轮合流)", async () => {
314
+ const record = recordSpy();
315
+ const h = createHarness();
316
+ const { id1, id2 } = await armTwoSends(h);
317
+ h.respondWatch(
318
+ 0,
319
+ JSON.stringify({ reason: "completed", sessionId: "child-1", settleSeq: 7, fulfillsN: 1 }),
320
+ );
321
+ h.respondWatch(
322
+ 1,
323
+ JSON.stringify({ reason: "completed", sessionId: "child-1", settleSeq: 8, fulfillsN: 1 }),
324
+ );
325
+ await flushMicrotasks();
326
+ await vi.advanceTimersByTimeAsync(50);
327
+ expect(record).toHaveBeenCalledTimes(2);
328
+ expect(record.mock.calls.map((c) => c[0]).sort()).toEqual([id1, id2].sort());
329
+ expect(emittedIds(h, "pending:unregister")).toHaveLength(2);
330
+ });
331
+
332
+ it("reason 分类:failed / stopped 应答 → 文案 status 对应(渲染侧 outcome 再映射)", async () => {
333
+ const record = recordSpy();
334
+ const h = createHarness();
335
+ const { id1, id2 } = await armTwoSends(h);
336
+ h.respondWatch(
337
+ 0,
338
+ JSON.stringify({ reason: "failed", sessionId: "child-1", settleSeq: 1, fulfillsN: 1 }),
339
+ );
340
+ h.respondWatch(
341
+ 1,
342
+ JSON.stringify({ reason: "stopped", sessionId: "child-1", settleSeq: 2, fulfillsN: 1 }),
343
+ );
344
+ await flushMicrotasks();
345
+ await vi.advanceTimersByTimeAsync(50);
346
+ expect(record).toHaveBeenCalledTimes(2);
347
+ const byId = new Map(record.mock.calls.map((c) => [c[0], c[1] as string]));
348
+ expect(byId.get(id1)).toContain('status "failed"');
349
+ expect(byId.get(id2)).toContain('status "stopped"');
350
+ });
351
+
352
+ it("跨 session 不合批(分拣键 sessionId 腿):同 settleSeq 不同 session → 两条 record", async () => {
353
+ const record = recordSpy();
354
+ const h = createHarness();
355
+ h.actionResults.send = () => SEND_OK;
356
+ await runToolCall(h, "send_to_session", { sessionId: "child-a", prompt: "x" });
357
+ await runToolCall(h, "send_to_session", { sessionId: "child-b", prompt: "y" });
358
+ await flushMicrotasks();
359
+ expect(h.watches).toHaveLength(2);
360
+ h.respondWatch(
361
+ 0,
362
+ JSON.stringify({ reason: "completed", sessionId: "child-a", settleSeq: 3, fulfillsN: 1 }),
363
+ );
364
+ h.respondWatch(
365
+ 1,
366
+ JSON.stringify({ reason: "completed", sessionId: "child-b", settleSeq: 3, fulfillsN: 1 }),
367
+ );
368
+ await flushMicrotasks();
369
+ await vi.advanceTimersByTimeAsync(50);
370
+ expect(record).toHaveBeenCalledTimes(2);
371
+ const sessions = record.mock.calls.map((c) => (c[2] as { sessionId: string }).sessionId);
372
+ expect(sessions.sort()).toEqual(["child-a", "child-b"]);
373
+ });
374
+
375
+ it("record 返回 false(幂等拒收/槽空降级)不改 unregister 语义", async () => {
376
+ plantLedger(() => false);
377
+ const h = createHarness();
378
+ await armTwoSends(h);
379
+ const respond = {
380
+ reason: "completed",
381
+ sessionId: "child-1",
382
+ settleSeq: 1,
383
+ fulfillsN: 1,
384
+ };
385
+ h.respondWatch(0, JSON.stringify(respond));
386
+ h.respondWatch(1, JSON.stringify(respond));
387
+ await flushMicrotasks();
388
+ await vi.advanceTimersByTimeAsync(50);
389
+ expect(emittedIds(h, "pending:unregister")).toHaveLength(2);
390
+ });
391
+ });
392
+
393
+ // ── 两例外(D3)─────────────────────────────────────────────────────────────────
394
+
395
+ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
396
+ it("例外1:cancelled / orphaned → 逐笔即时 unregister(50ms 窗前即到),零 record", async () => {
397
+ const record = recordSpy();
398
+ const h = createHarness();
399
+ const { id1, id2 } = await armTwo();
400
+ h.respondWatch(0, JSON.stringify({ reason: "cancelled", sessionId: "child-1" }));
401
+ h.respondWatch(1, JSON.stringify({ reason: "orphaned", sessionId: "child-1" }));
402
+ await flushMicrotasks();
403
+
404
+ // 逐笔即时(不被攒批窗延迟)
405
+ expect(emittedIds(h, "pending:unregister").sort()).toEqual([id1, id2].sort());
406
+ expect(record).not.toHaveBeenCalled();
407
+
408
+ await vi.advanceTimersByTimeAsync(50);
409
+ expect(record).not.toHaveBeenCalled();
410
+
411
+ async function armTwo() {
412
+ h.actionResults.send = () => SEND_OK;
413
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "a" });
414
+ await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "b" });
415
+ await flushMicrotasks();
416
+ return { id1: emittedIds(h, "pending:register")[0], id2: emittedIds(h, "pending:register")[1] };
417
+ }
418
+ });
419
+
420
+ it("例外2:同 (sessionId, deathSeq) 首条死亡新闻 record + fulfills N,同键后续仅 unregister;新 deathSeq 自然发声", async () => {
421
+ const record = recordSpy();
422
+ const h = createHarness();
423
+ const lifetime = "sm-11111111-2222-3333-4444-555555555555";
424
+ h.actionResults.create = () =>
425
+ JSON.stringify({
426
+ sessionId: "child-d",
427
+ status: "created",
428
+ willNotify: true,
429
+ lifetimeNotifyId: lifetime,
430
+ });
431
+ await runToolCall(h, "create_managed_session", { cwd: "/w", label: "doomed", prompt: "go" });
432
+ await flushMicrotasks();
433
+ const claimId = emittedIds(h, "pending:register").find((id) => id !== lifetime);
434
+ expect(claimId).toBeDefined();
435
+ expect(h.watches).toHaveLength(2);
436
+
437
+ // 同 deathSeq 的 claim + lifetime 两笔应答(claim 携 exit 诊断 + transcript 指针)
438
+ const death = {
439
+ reason: "exited",
440
+ sessionId: "child-d",
441
+ deathSeq: 3,
442
+ fulfillsN: 1,
443
+ exitCode: 1,
444
+ stderrTail: "boom",
445
+ sessionFilePath: "/data/agent/sessions/child-d.jsonl",
446
+ };
447
+ h.respondWatch(0, JSON.stringify(death));
448
+ h.respondWatch(1, JSON.stringify(death));
449
+ await flushMicrotasks();
450
+ await vi.advanceTimersByTimeAsync(50);
451
+
452
+ expect(record).toHaveBeenCalledTimes(1);
453
+ const [id, content, details] = record.mock.calls[0] as [string, string, Record<string, unknown>];
454
+ expect(id).toBe(claimId); // 首条 = 批内首个响应(claim 先应答)
455
+ expect(content).toContain(
456
+ 'Managed session "doomed" (child-d) finished with status "exited" (exit code: 1).',
457
+ );
458
+ expect(content).toContain("(fulfills 1 request)");
459
+ expect(content).toContain("\nStderr: boom");
460
+ // D-transcript:payload.sessionFilePath 透传 → Full transcript 指针行恢复
461
+ expect(content).toContain("\nFull transcript: /data/agent/sessions/child-d.jsonl");
462
+ expect(details.reason).toBe("exited");
463
+ expect(emittedIds(h, "pending:unregister").sort()).toEqual([claimId, lifetime].sort());
464
+
465
+ // 新死亡(新 deathSeq,respawn 后再死)→ 新闻自然发声;未携 sessionFilePath → 整行省略
466
+ h.actionResults.send = () => SEND_OK;
467
+ await runToolCall(h, "send_to_session", { sessionId: "child-d", prompt: "x" });
468
+ await flushMicrotasks();
469
+ expect(h.watches).toHaveLength(3);
470
+ h.respondWatch(
471
+ 2,
472
+ JSON.stringify({ reason: "exited", sessionId: "child-d", deathSeq: 4, fulfillsN: 0, exitCode: 2 }),
473
+ );
474
+ await flushMicrotasks();
475
+ await vi.advanceTimersByTimeAsync(50);
476
+ expect(record).toHaveBeenCalledTimes(2);
477
+ expect(record.mock.calls[1][0]).toBe(emittedIds(h, "pending:register")[2]);
478
+ expect(String(record.mock.calls[1][1])).toContain("(exit code: 2).");
479
+ expect(String(record.mock.calls[1][1])).not.toContain("fulfills");
480
+ expect(String(record.mock.calls[1][1])).not.toContain("Full transcript");
481
+ });
482
+
483
+ it("例外2 跨 reason 组同槽:claim 'exited' 与 lifetime 'deleted' 同 deathSeq → 仅首条发声(分拣组保序)", async () => {
484
+ const record = recordSpy();
485
+ const lifetime = "sm-11111111-2222-3333-4444-555555555555";
486
+ const claimId = "sm-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
487
+ const h = createHarness([
488
+ { customType: "pending:register", data: { id: claimId, type: "session", name: "D" } },
489
+ { customType: "pending:register", data: { id: lifetime, type: "session", name: "D" } },
490
+ ]);
491
+ // 经收口腿开两条 watch(开表序 = entries 序,claim 先到)
492
+ h.handlers.session_start({ type: "session_start" }, h.ctx);
493
+ await flushMicrotasks();
494
+ expect(h.watches).toHaveLength(2);
495
+
496
+ h.respondWatch(
497
+ 0,
498
+ JSON.stringify({ reason: "exited", sessionId: "child-d", deathSeq: 9, fulfillsN: 1 }),
499
+ );
500
+ h.respondWatch(
501
+ 1,
502
+ JSON.stringify({ reason: "deleted", sessionId: "child-d", deathSeq: 9, fulfillsN: 0 }),
503
+ );
504
+ await flushMicrotasks();
505
+ await vi.advanceTimersByTimeAsync(50);
506
+
507
+ expect(record).toHaveBeenCalledTimes(1);
508
+ expect(record.mock.calls[0][0]).toBe(claimId); // 先到先占槽(claim 组先入队)
509
+ expect(String(record.mock.calls[0][1])).toContain('status "exited"');
510
+ expect(emittedIds(h, "pending:unregister").sort()).toEqual([claimId, lifetime].sort());
511
+ const unregisterPairs = h.emitted
512
+ .filter((e) => e.event === "pending:unregister")
513
+ .map((e) => [String(e.data.id), String(e.data.reason)])
514
+ .sort();
515
+ expect(unregisterPairs).toEqual(
516
+ [
517
+ [claimId, "exited"],
518
+ [lifetime, "deleted"],
519
+ ].sort(),
520
+ );
521
+ });
522
+ });
523
+
524
+ // ── 兼容与防御(D6 象限 / 形状守卫)─────────────────────────────────────────────
525
+
526
+ describe("兼容与防御", () => {
527
+ it("旧 runtime 象限:respond 'null' → 折叠 cancelled 静默 unregister,零 record", async () => {
528
+ const { record, h } = await armSendAndRespondWatch("null");
529
+ expect(record).not.toHaveBeenCalled();
530
+ expect(emittedIds(h, "pending:unregister")).toHaveLength(1);
531
+ // D6 显式静默豁免:null 不是漂移信号,零 warn 留痕
532
+ expect(loggerMock.warn).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it("畸形/词表外 payload → 折叠 cancelled 收口(不落 default 误标、不产通知)+ 漂移 warn 留痕", async () => {
536
+ const { record, h } = await armSendAndRespondWatch(JSON.stringify({ reason: "some-future-reason" }));
537
+ expect(record).not.toHaveBeenCalled();
538
+ expect(emittedIds(h, "pending:unregister")).toHaveLength(1);
539
+ // 协议漂移信号必须留痕:warn 带 notifyId + 漂移形态摘要(词表外 reason 原词)
540
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
541
+ const [warnMsg, warnData] = loggerMock.warn.mock.calls.at(-1)!;
542
+ expect(warnMsg).toContain("folding cancelled");
543
+ expect(warnData).toMatchObject({
544
+ notifyId: expect.stringMatching(/^sm-/),
545
+ drift: expect.stringContaining("some-future-reason"),
546
+ });
547
+ });
548
+
549
+ it("select 侧 cancelled(应答未达,resolve undefined)→ 不动 pending(残留交下次 session_start 收口腿)", async () => {
550
+ const { record, h } = await armSendAndRespondWatch(undefined);
551
+ expect(emittedIds(h, "pending:unregister")).toHaveLength(0);
552
+ expect(record).not.toHaveBeenCalled();
553
+ });
554
+
555
+ it("session_compact → compactionCheck 接线(D3 装配纪律④)", () => {
556
+ const compactionCheck = vi.fn(() => 0);
557
+ plantLedger(vi.fn(() => true) as never, compactionCheck);
558
+ const h = createHarness();
559
+ expect(h.handlers.session_compact).toBeTypeOf("function");
560
+ h.handlers.session_compact({ type: "session_compact" }, h.ctx);
561
+ expect(compactionCheck).toHaveBeenCalledTimes(1);
562
+ });
563
+ });