@zhushanwen/pi-subagent-workflow 8.1.1 → 8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -47,7 +47,8 @@
47
47
  "dependencies": {
48
48
  "ajv": "^8.20.0",
49
49
  "yaml": "^2.9.0",
50
- "@xyz-agent/extension-protocol": "0.5.1",
50
+ "@xyz-agent/extension-protocol": "0.6.0",
51
+ "@xyz-agent/session-delivery": "0.2.0",
51
52
  "@zhushanwen/pi-extension-logger": "0.2.2",
52
53
  "@zhushanwen/pi-file-lock": "0.1.1"
53
54
  },
@@ -0,0 +1,53 @@
1
+ {
2
+ "_meta": "Golden snapshots for BgNotifier.buildLlmContent — captured pre-migration (commit HEAD at time of capture). Used by migration verification to ensure zero behavioral regression.",
3
+ "single_closed_success": {
4
+ "input": { "id": "sa-ptr-7", "status": "closed", "agent": "worker", "result": "done", "startedAt": 1, "endedAt": 2 },
5
+ "output": "Subagent \"worker\" (sa-ptr-7) completed. Result:\ndone"
6
+ },
7
+ "single_closed_with_sessionfile": {
8
+ "input": { "id": "sa-ptr-2", "status": "closed", "agent": "w", "result": "final result", "sessionFile": "/tmp/sessions/child-2.jsonl", "startedAt": 1, "endedAt": 2 },
9
+ "output": "Subagent \"w\" (sa-ptr-2) completed. Result:\nfinal result\n\nFull transcript: /tmp/sessions/child-2.jsonl"
10
+ },
11
+ "single_running_round": {
12
+ "input": { "id": "sa-ptr-4", "status": "running", "agent": "w", "round": 2, "result": "round2 text", "startedAt": 1, "endedAt": 2 },
13
+ "output": "Subagent \"w\" (sa-ptr-4) finished a round. Reply:\nround2 text"
14
+ },
15
+ "single_running_round_with_sessionfile": {
16
+ "input": { "id": "sa-ptr-1", "status": "running", "agent": "w", "round": 1, "result": "round1 text", "sessionFile": "/tmp/sessions/child-1.jsonl", "startedAt": 1, "endedAt": 2 },
17
+ "output": "Subagent \"w\" (sa-ptr-1) finished a round. Reply:\nround1 text\n\nFull transcript: /tmp/sessions/child-1.jsonl"
18
+ },
19
+ "single_cancelled": {
20
+ "input": { "id": "sa-ptr-5", "status": "closed", "closedReason": "cancelled", "agent": "w", "startedAt": 1, "endedAt": 2 },
21
+ "output": "Subagent \"w\" (sa-ptr-5) cancelled."
22
+ },
23
+ "single_gc_failed": {
24
+ "input": { "id": "sa-ptr-6", "status": "closed", "closedReason": "gc", "agent": "w", "error": "spawn EPIPE", "startedAt": 1, "endedAt": 2 },
25
+ "output": "Subagent \"w\" (sa-ptr-6) failed: spawn EPIPE"
26
+ },
27
+ "single_gc_failed_with_patchfile": {
28
+ "input": { "id": "sa-ptr-8", "status": "closed", "closedReason": "gc", "agent": "w", "error": "spawn EPIPE", "patchFile": "/tmp/patches/sa-ptr-8.patch", "startedAt": 1, "endedAt": 2 },
29
+ "output": "Subagent \"w\" (sa-ptr-8) failed: spawn EPIPE"
30
+ },
31
+ "single_closed_with_patchfile_and_sessionfile": {
32
+ "input": { "id": "sa-ptr-3", "status": "closed", "agent": "w", "result": "did work", "patchFile": "/tmp/patches/sa-ptr-3.patch", "sessionFile": "/tmp/sessions/child-3.jsonl", "startedAt": 1, "endedAt": 2 },
33
+ "output_contains": "git apply /tmp/patches/sa-ptr-3.patch",
34
+ "output_ends_with": "\n\nFull transcript: /tmp/sessions/child-3.jsonl"
35
+ },
36
+ "single_closed_totalrounds": {
37
+ "input": { "id": "sa-rounds-1", "status": "closed", "agent": "w", "totalRounds": 3, "result": "", "sessionFile": "/tmp/sessions/child-rounds.jsonl", "startedAt": 1, "endedAt": 2 },
38
+ "output": "Subagent \"w\" (sa-rounds-1) completed after 3 rounds. Result:\n\n\nFull transcript: /tmp/sessions/child-rounds.jsonl"
39
+ },
40
+ "single_closed_no_totalrounds": {
41
+ "input": { "id": "sa-rounds-2", "status": "closed", "agent": "w", "result": "done", "sessionFile": "/tmp/sessions/child-oneshot.jsonl", "startedAt": 1, "endedAt": 2 },
42
+ "output": "Subagent \"w\" (sa-rounds-2) completed. Result:\ndone\n\nFull transcript: /tmp/sessions/child-oneshot.jsonl"
43
+ },
44
+ "batch_two_closed": {
45
+ "description": "Two closed records merged in one message (simulating 60s merge window)",
46
+ "records": [
47
+ { "id": "batch-1", "status": "closed", "agent": "w1", "result": "result1", "startedAt": 1, "endedAt": 2 },
48
+ { "id": "batch-2", "status": "closed", "agent": "w2", "result": "result2", "startedAt": 3, "endedAt": 4 }
49
+ ],
50
+ "separator": "\n\n---\n\n",
51
+ "details_shape": { "batch": true, "items": "array-of-records" }
52
+ }
53
+ }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
 
15
- import { BgNotifier, type NotifierHost } from "../notifier.ts";
15
+ import { createNotifier, type BgNotifier, type NotifierHost } from "../notifier.ts";
16
16
 
17
17
  /** mock host:捕获所有 sendMessage 调用 + 控制 hasRunningBackground + isIdle。 */
18
18
  function makeMockHost(): NotifierHost & {
@@ -35,11 +35,11 @@ function makeMockHost(): NotifierHost & {
35
35
 
36
36
  describe("BgNotifier.flushPendingNotifications — deliverAs 契约", () => {
37
37
  let host: ReturnType<typeof makeMockHost>;
38
- let notifier: BgNotifier;
38
+ let notifier: BgNotifier;
39
39
 
40
40
  beforeEach(() => {
41
41
  host = makeMockHost();
42
- notifier = new BgNotifier(host);
42
+ notifier = createNotifier(host);
43
43
  });
44
44
 
45
45
  afterEach(() => {
@@ -87,7 +87,7 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
87
87
  beforeEach(() => {
88
88
  vi.useFakeTimers();
89
89
  host = makeMockHost();
90
- notifier = new BgNotifier(host);
90
+ notifier = createNotifier(host);
91
91
  });
92
92
 
93
93
  afterEach(() => {
@@ -152,7 +152,7 @@ describe("BgNotifier — isIdle gate 竞态修复", () => {
152
152
  sendMessage: (message, options) => host.sendMessage(message, options),
153
153
  hasRunningBackground: () => false,
154
154
  };
155
- const legacyNotifier = new BgNotifier(legacyHost);
155
+ const legacyNotifier = createNotifier(legacyHost);
156
156
  legacyNotifier.notify({
157
157
  id: "bg-legacy-1",
158
158
  status: "closed",
@@ -192,7 +192,7 @@ describe("BgNotifier dedup 按轮次(G1 决策 9:对话模式豁免 60s dedu
192
192
  beforeEach(() => {
193
193
  host = makeMockHost();
194
194
  // hasRunningBackground=false → notify 立即 flush(不排队);dedup 仍在 push 前生效
195
- notifier = new BgNotifier(host);
195
+ notifier = createNotifier(host);
196
196
  });
197
197
 
198
198
  afterEach(() => {
@@ -250,7 +250,7 @@ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile
250
250
  beforeEach(() => {
251
251
  host = makeMockHost();
252
252
  // hasRunningBackground=false → notify 立即 flush,sendMessageCalls 恰 1 条
253
- notifier = new BgNotifier(host);
253
+ notifier = createNotifier(host);
254
254
  });
255
255
 
256
256
  afterEach(() => {
@@ -386,3 +386,189 @@ describe("BgNotifier buildLlmContent 指针行(wave2:chatMode sessionFile
386
386
  );
387
387
  });
388
388
  });
389
+
390
+ describe("BgNotifier — subscribeSettled 装配(must-fix #4 / D8 settled 边沿驱动)", () => {
391
+ let host: ReturnType<typeof makeMockHost>;
392
+ let notifier: BgNotifier;
393
+ /** 内核经 port.subscribeSettled 注册的 settled 边沿回调(测试手动触发模拟 pi 事件)。 */
394
+ let settledEdges: Array<() => void>;
395
+
396
+ beforeEach(() => {
397
+ vi.useFakeTimers();
398
+ host = makeMockHost();
399
+ settledEdges = [];
400
+ // host 注入原生订阅能力;notifier port 内部用 disposed 标志包装(D8 适配)
401
+ host.onAgentSettled = (handler) => {
402
+ settledEdges.push(handler);
403
+ };
404
+ notifier = createNotifier(host);
405
+ });
406
+
407
+ afterEach(() => {
408
+ notifier.dispose();
409
+ vi.useRealTimers();
410
+ });
411
+
412
+ it("busy 入队 → settled 事件触发 → flush 送达(不依赖退避轮询)", () => {
413
+ host.isIdle.mockReturnValue(false);
414
+ notifier.notify({
415
+ id: "bg-settled-1",
416
+ status: "closed",
417
+ agent: "worker",
418
+ result: "ok",
419
+ startedAt: Date.now(),
420
+ endedAt: Date.now(),
421
+ });
422
+
423
+ // busy 入队:内核装配了 subscribeSettled → settled 边沿驱动,注册过订阅
424
+ expect(host.sendMessageCalls).toHaveLength(0);
425
+ expect(settledEdges.length).toBeGreaterThanOrEqual(1);
426
+
427
+ // 有订阅装配下内核不走退避强发——推进远超退避上限(50 × 100ms)仍不发送
428
+ vi.advanceTimersByTime(10_000);
429
+ expect(host.sendMessageCalls).toHaveLength(0);
430
+
431
+ // settled 边沿(isIdle 已先于事件复位,agent-session.js:327-336)→ flush 送达
432
+ host.isIdle.mockReturnValue(true);
433
+ settledEdges[0]!();
434
+
435
+ expect(host.sendMessageCalls).toHaveLength(1);
436
+ expect(host.sendMessageCalls[0]!.options).toMatchObject({
437
+ triggerTurn: true,
438
+ deliverAs: "steer",
439
+ });
440
+ });
441
+
442
+ it("dispose 后 settled 边沿不再触发发送(内核 disposed 拦截)", () => {
443
+ host.isIdle.mockReturnValue(false);
444
+ notifier.notify({
445
+ id: "bg-settled-2",
446
+ status: "closed",
447
+ agent: "worker",
448
+ result: "ok",
449
+ startedAt: Date.now(),
450
+ endedAt: Date.now(),
451
+ });
452
+ notifier.dispose();
453
+
454
+ host.isIdle.mockReturnValue(true);
455
+ settledEdges[0]!();
456
+
457
+ expect(host.sendMessageCalls).toHaveLength(0);
458
+ });
459
+ });
460
+
461
+ describe("BgNotifier — revive 重建内核 handle(must-fix #5)", () => {
462
+ let host: ReturnType<typeof makeMockHost>;
463
+ let notifier: BgNotifier;
464
+
465
+ beforeEach(() => {
466
+ host = makeMockHost();
467
+ notifier = createNotifier(host);
468
+ });
469
+
470
+ afterEach(() => {
471
+ notifier.dispose();
472
+ });
473
+
474
+ it("dispose → notify 静默丢弃 → revive → notify 恢复送达", () => {
475
+ notifier.notify({
476
+ id: "bg-revive-1",
477
+ status: "closed",
478
+ agent: "worker",
479
+ result: "ok",
480
+ startedAt: 1,
481
+ endedAt: 2,
482
+ });
483
+ expect(host.sendMessageCalls).toHaveLength(1);
484
+
485
+ // session_shutdown:dispose(内核 handle 销毁,disposed 不可逆)
486
+ notifier.dispose();
487
+
488
+ // dispose 窗口内的 notify:外层 disposed 短路,静默丢弃(旧有语义)
489
+ notifier.notify({
490
+ id: "bg-revive-2",
491
+ status: "closed",
492
+ agent: "worker",
493
+ result: "dropped",
494
+ startedAt: 3,
495
+ endedAt: 4,
496
+ });
497
+ expect(host.sendMessageCalls).toHaveLength(1);
498
+
499
+ // /resume /fork /new 后的 revive:必须重建内核 handle——旧实现仅复位外层标志,
500
+ // 内核已 dispose,此后所有 notify 被内核静默吞(通知永久丢失)
501
+ notifier.revive();
502
+ notifier.notify({
503
+ id: "bg-revive-3",
504
+ status: "closed",
505
+ agent: "worker",
506
+ result: "alive again",
507
+ startedAt: 5,
508
+ endedAt: 6,
509
+ });
510
+
511
+ expect(host.sendMessageCalls).toHaveLength(2);
512
+ const msg = host.sendMessageCalls[1]!.message as { content: string };
513
+ expect(msg.content).toContain("bg-revive-3");
514
+ });
515
+
516
+ it("revive 后同 id 重复通知不被旧生命周期的 dedup LRU 吞(新 handle 状态复位)", () => {
517
+ notifier.notify({ id: "bg-revive-dup", status: "closed", agent: "w", result: "r1", startedAt: 1, endedAt: 2 });
518
+ notifier.dispose();
519
+ notifier.revive();
520
+ // 同 id 第二次:旧 handle 的 dedup LRU 已随 dispose 清空 + 重建,不被吞
521
+ notifier.notify({ id: "bg-revive-dup", status: "closed", agent: "w", result: "r2", startedAt: 3, endedAt: 4 });
522
+ expect(host.sendMessageCalls).toHaveLength(2);
523
+ });
524
+ });
525
+
526
+ describe("U3_UNIT: createNotifier unit verification", () => {
527
+ it("createNotifier returns object with notify/flush/dispose/revive", () => {
528
+ const host = makeMockHost();
529
+ const notifier = createNotifier(host);
530
+ expect(typeof notifier.notify).toBe("function");
531
+ expect(typeof notifier.flushPendingNotifications).toBe("function");
532
+ expect(typeof notifier.dispose).toBe("function");
533
+ expect(typeof notifier.revive).toBe("function");
534
+ notifier.dispose();
535
+ });
536
+
537
+ it("notify calls host.sendMessage with customType=subagent-bg-notify", () => {
538
+ const host = makeMockHost();
539
+ const notifier = createNotifier(host);
540
+ notifier.notify({
541
+ id: "u3-test-1", status: "closed", agent: "worker", result: "ok",
542
+ startedAt: 1, endedAt: 2,
543
+ });
544
+ expect(host.sendMessageCalls).toHaveLength(1);
545
+ const msg = host.sendMessageCalls[0]!.message as { customType: string };
546
+ expect(msg.customType).toBe("subagent-bg-notify");
547
+ notifier.dispose();
548
+ });
549
+
550
+ it("notify with mergeHoldActive=true defers flush until explicit call", () => {
551
+ const sendMessageCalls: unknown[] = [];
552
+ const host: NotifierHost = {
553
+ sendMessage: (msg) => { sendMessageCalls.push(msg); },
554
+ hasRunningBackground: () => true,
555
+ isIdle: () => true,
556
+ };
557
+ const notifier = createNotifier(host);
558
+ notifier.notify({
559
+ id: "u3-merge-1", status: "closed", agent: "w", result: "r1",
560
+ startedAt: 1, endedAt: 2,
561
+ });
562
+ notifier.notify({
563
+ id: "u3-merge-2", status: "closed", agent: "w", result: "r2",
564
+ startedAt: 3, endedAt: 4,
565
+ });
566
+ // mergeHoldActive=true → messages queued, not sent yet
567
+ expect(sendMessageCalls).toHaveLength(0);
568
+ notifier.flushPendingNotifications();
569
+ expect(sendMessageCalls).toHaveLength(1);
570
+ const content = (sendMessageCalls[0] as { content: string }).content;
571
+ expect(content).toContain("\n\n---\n\n");
572
+ notifier.dispose();
573
+ });
574
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Golden snapshot 固化测试:记录 notifier.buildLlmContent 在各种输入下的输出。
3
+ *
4
+ * 此测试在迁移**前**运行并固化结果——迁移后必须逐字节一致(G4)。
5
+ *
6
+ * 这些测试直接测试 BgNotifier 的输出内容,不涉及 send/handle 机制,
7
+ * 因此迁移后应通过内核装配 + 同一 buildLlmContent 产生相同输出。
8
+ */
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { describe, expect, it } from "vitest";
11
+ import { createNotifier, type BgNotifyRecord, type NotifierHost } from "../notifier.ts";
12
+ import { renderBgNotifyMessage } from "../../interface/bg-notify-render.ts";
13
+
14
+ /** 渲染锁用 mock theme(与 bg-notify-render.test.ts 同款:透传文本,记录色 token)。 */
15
+ function makeRenderTheme(): { theme: Theme } {
16
+ return {
17
+ theme: {
18
+ fg: (_tag: string, text: string) => text,
19
+ bold: (text: string) => text,
20
+ bg: (_color: string, text: string) => text,
21
+ } as unknown as Theme,
22
+ };
23
+ }
24
+
25
+ /** 创建 mock host,notifier 会立即 flush(hasRunningBackground=false)。 */
26
+ function makeMockHost(): NotifierHost {
27
+ return {
28
+ sendMessage: () => {},
29
+ hasRunningBackground: () => false,
30
+ isIdle: () => true,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * 调用 notifier.notify 并捕获 sendMessage 的 content 参数。
36
+ * 前置条件:hasRunningBackground=false → notify 立即 flush。
37
+ */
38
+ function captureNotificationContent(record: BgNotifyRecord): string {
39
+ let capturedContent = "";
40
+ const host: NotifierHost = {
41
+ sendMessage: (msg) => {
42
+ capturedContent = (msg as { content: string }).content;
43
+ },
44
+ hasRunningBackground: () => false,
45
+ isIdle: () => true,
46
+ };
47
+ const notifier = createNotifier(host);
48
+ notifier.notify(record);
49
+ notifier.dispose();
50
+ return capturedContent;
51
+ }
52
+
53
+ describe("U3_UNIT notifier golden snapshots (pre-migration freeze)", () => {
54
+ it("single closed success — one-shot completed notification", () => {
55
+ const content = captureNotificationContent({
56
+ id: "sa-ptr-7",
57
+ status: "closed",
58
+ agent: "worker",
59
+ result: "done",
60
+ startedAt: 1,
61
+ endedAt: 2,
62
+ });
63
+ expect(content).toBe('Subagent "worker" (sa-ptr-7) completed. Result:\ndone');
64
+ });
65
+
66
+ it("single closed + sessionFile — pointer line appended", () => {
67
+ const content = captureNotificationContent({
68
+ id: "sa-ptr-2",
69
+ status: "closed",
70
+ agent: "w",
71
+ result: "final result",
72
+ sessionFile: "/tmp/sessions/child-2.jsonl",
73
+ startedAt: 1,
74
+ endedAt: 2,
75
+ });
76
+ expect(content).toBe(
77
+ 'Subagent "w" (sa-ptr-2) completed. Result:\nfinal result\n\nFull transcript: /tmp/sessions/child-2.jsonl',
78
+ );
79
+ });
80
+
81
+ it("single running round (chatMode) — round notification", () => {
82
+ const content = captureNotificationContent({
83
+ id: "sa-ptr-4",
84
+ status: "running",
85
+ agent: "w",
86
+ round: 2,
87
+ result: "round2 text",
88
+ startedAt: 1,
89
+ endedAt: 2,
90
+ });
91
+ expect(content).toBe('Subagent "w" (sa-ptr-4) finished a round. Reply:\nround2 text');
92
+ });
93
+
94
+ it("single running round + sessionFile — pointer appended to round notification", () => {
95
+ const content = captureNotificationContent({
96
+ id: "sa-ptr-1",
97
+ status: "running",
98
+ agent: "w",
99
+ round: 1,
100
+ result: "round1 text",
101
+ sessionFile: "/tmp/sessions/child-1.jsonl",
102
+ startedAt: 1,
103
+ endedAt: 2,
104
+ });
105
+ expect(content).toBe(
106
+ 'Subagent "w" (sa-ptr-1) finished a round. Reply:\nround1 text\n\nFull transcript: /tmp/sessions/child-1.jsonl',
107
+ );
108
+ });
109
+
110
+ it("single cancelled — cancelled message, no pointer", () => {
111
+ const content = captureNotificationContent({
112
+ id: "sa-ptr-5",
113
+ status: "closed",
114
+ closedReason: "cancelled",
115
+ agent: "w",
116
+ startedAt: 1,
117
+ endedAt: 2,
118
+ });
119
+ expect(content).toBe('Subagent "w" (sa-ptr-5) cancelled.');
120
+ });
121
+
122
+ it("single gc-failed — error message, no pointer", () => {
123
+ const content = captureNotificationContent({
124
+ id: "sa-ptr-6",
125
+ status: "closed",
126
+ closedReason: "gc",
127
+ agent: "w",
128
+ error: "spawn EPIPE",
129
+ startedAt: 1,
130
+ endedAt: 2,
131
+ });
132
+ expect(content).toBe('Subagent "w" (sa-ptr-6) failed: spawn EPIPE');
133
+ });
134
+
135
+ it("single gc-failed + patchFile — failed takes priority, no patch hint", () => {
136
+ const content = captureNotificationContent({
137
+ id: "sa-ptr-8",
138
+ status: "closed",
139
+ closedReason: "gc",
140
+ agent: "w",
141
+ error: "spawn EPIPE",
142
+ patchFile: "/tmp/patches/sa-ptr-8.patch",
143
+ startedAt: 1,
144
+ endedAt: 2,
145
+ });
146
+ expect(content).toBe('Subagent "w" (sa-ptr-8) failed: spawn EPIPE');
147
+ });
148
+
149
+ it("single closed + patchFile + sessionFile — patch hint + pointer", () => {
150
+ const content = captureNotificationContent({
151
+ id: "sa-ptr-3",
152
+ status: "closed",
153
+ agent: "w",
154
+ result: "did work",
155
+ patchFile: "/tmp/patches/sa-ptr-3.patch",
156
+ sessionFile: "/tmp/sessions/child-3.jsonl",
157
+ startedAt: 1,
158
+ endedAt: 2,
159
+ });
160
+ expect(content).toContain("git apply /tmp/patches/sa-ptr-3.patch");
161
+ expect(content.endsWith("\n\nFull transcript: /tmp/sessions/child-3.jsonl")).toBe(true);
162
+ });
163
+
164
+ it("closed + totalRounds — chatMode close with round stats", () => {
165
+ const content = captureNotificationContent({
166
+ id: "sa-rounds-1",
167
+ status: "closed",
168
+ agent: "w",
169
+ totalRounds: 3,
170
+ result: "",
171
+ sessionFile: "/tmp/sessions/child-rounds.jsonl",
172
+ startedAt: 1,
173
+ endedAt: 2,
174
+ });
175
+ expect(content).toBe(
176
+ "Subagent \"w\" (sa-rounds-1) completed after 3 rounds. Result:\n\n\nFull transcript: /tmp/sessions/child-rounds.jsonl",
177
+ );
178
+ });
179
+
180
+ it("closed no totalRounds — one-shot, no round stats", () => {
181
+ const content = captureNotificationContent({
182
+ id: "sa-rounds-2",
183
+ status: "closed",
184
+ agent: "w",
185
+ result: "done",
186
+ sessionFile: "/tmp/sessions/child-oneshot.jsonl",
187
+ startedAt: 1,
188
+ endedAt: 2,
189
+ });
190
+ expect(content).toBe(
191
+ 'Subagent "w" (sa-rounds-2) completed. Result:\ndone\n\nFull transcript: /tmp/sessions/child-oneshot.jsonl',
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("notifier golden — batch merge (60s window, two records)", () => {
197
+ it("two closed records joined with separator", () => {
198
+ let capturedContent = "";
199
+ let capturedDetails: unknown;
200
+ const host: NotifierHost = {
201
+ sendMessage: (msg) => {
202
+ capturedContent = (msg as { content: string }).content;
203
+ capturedDetails = (msg as { details: unknown }).details;
204
+ },
205
+ hasRunningBackground: () => true, // true → 合批窗口激活
206
+ isIdle: () => true,
207
+ };
208
+ const notifier = createNotifier(host);
209
+
210
+ // 两条通知入队(不立即 flush——hasRunningBackground=true)
211
+ notifier.notify({
212
+ id: "batch-1", status: "closed", agent: "w1", result: "result1",
213
+ startedAt: 1, endedAt: 2,
214
+ });
215
+ notifier.notify({
216
+ id: "batch-2", status: "closed", agent: "w2", result: "result2",
217
+ startedAt: 3, "endedAt": 4,
218
+ });
219
+
220
+ // 手动 flush(模拟窗口到期)
221
+ notifier.flushPendingNotifications();
222
+
223
+ expect(capturedContent).toBe(
224
+ 'Subagent "w1" (batch-1) completed. Result:\nresult1\n\n---\n\nSubagent "w2" (batch-2) completed. Result:\nresult2',
225
+ );
226
+ // details 结构(must-fix #13 / #6 锁死):items 元素必须是 record 本体(顶层
227
+ // status/agent/id/result——bg-notify-render 的 extractBgNotifyRecord 按此读取),
228
+ // 拦截内核 items 规则回归(曾错装 payload 导致渲染降级默认样式)。
229
+ const details = capturedDetails as { batch: boolean; items: unknown[] };
230
+ expect(details.batch).toBe(true);
231
+ expect(details.items).toHaveLength(2);
232
+ expect(details.items[0]).toMatchObject({
233
+ id: "batch-1",
234
+ status: "closed",
235
+ agent: "w1",
236
+ result: "result1",
237
+ startedAt: 1,
238
+ endedAt: 2,
239
+ });
240
+ expect(details.items[1]).toMatchObject({
241
+ id: "batch-2",
242
+ status: "closed",
243
+ agent: "w2",
244
+ result: "result2",
245
+ });
246
+ // 端到端渲染锁(#6):合批 details 直接喂 bg-notify-render 必须走批量分支成功渲染
247
+ // (非 undefined 兜底),两条 agent 均可见。
248
+ const { theme } = makeRenderTheme();
249
+ const comp = renderBgNotifyMessage(
250
+ { details: capturedDetails },
251
+ { expanded: false },
252
+ theme,
253
+ );
254
+ expect(comp).toBeDefined();
255
+ const joined = comp!.render(80).join("\n");
256
+ expect(joined).toContain("w1");
257
+ expect(joined).toContain("w2");
258
+
259
+ notifier.dispose();
260
+ });
261
+ });
@@ -15,8 +15,8 @@
15
15
  import { describe, expect, it } from "vitest";
16
16
 
17
17
  import { completeRecord, createRecord, tryTransition } from "../execution-record.ts";
18
- import { BgNotifier } from "../notifier.ts";
19
- import type { BgNotifyRecord, NotifierHost } from "../notifier.ts";
18
+ import { createNotifier } from "../notifier.ts";
19
+ import type { BgNotifyRecord, BgNotifier, NotifierHost } from "../notifier.ts";
20
20
  import { mapExternalState } from "../../interface/subagent-actions.ts";
21
21
  import { statusGlyph } from "../../interface/format.ts";
22
22
  import type { ClosedReason, ExecutionRecord, ExecutionStatus } from "../types.ts";
@@ -205,7 +205,7 @@ describe("BgNotifier dedupKey", () => {
205
205
 
206
206
  it("同 id 不同 round 不被去重(round 参与 dedup key)", () => {
207
207
  const host = createMockHost(false);
208
- const notifier = new BgNotifier(host);
208
+ const notifier = createNotifier(host);
209
209
  const sent: unknown[] = [];
210
210
  // 重写 sendMessage 捕获
211
211
  (host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
@@ -238,7 +238,7 @@ describe("BgNotifier dedupKey", () => {
238
238
 
239
239
  it("不同 id 不被去重", () => {
240
240
  const host = createMockHost(false);
241
- const notifier = new BgNotifier(host);
241
+ const notifier = createNotifier(host);
242
242
  const sent: unknown[] = [];
243
243
  (host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
244
244
 
@@ -267,7 +267,7 @@ describe("BgNotifier dedupKey", () => {
267
267
 
268
268
  it("closed status 被正确入队", () => {
269
269
  const host = createMockHost(false);
270
- const notifier = new BgNotifier(host);
270
+ const notifier = createNotifier(host);
271
271
  const sent: unknown[] = [];
272
272
  (host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
273
273