@zhushanwen/pi-subagent-workflow 8.1.1 → 8.3.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 +6 -5
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/notifier-flush.test.ts +193 -7
- package/src/execution/__tests__/notifier-golden-snapshot.test.ts +261 -0
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +211 -7
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-pending.ts +4 -1
- package/src/execution/subagent-service.ts +11 -4
- package/src/execution/ui-request-handler-factory.ts +40 -3
- package/src/index.ts +3 -0
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +104 -0
- package/src/injectors/__tests__/model-list-injector.test.ts +139 -0
- package/src/injectors/__tests__/subagent-list-injector.test.ts +59 -69
- package/src/injectors/__tests__/workflow-list-injector.test.ts +57 -67
- package/src/injectors/model-list-injector.ts +138 -0
- package/src/injectors/subagent-list-injector.ts +33 -24
- package/src/injectors/workflow-list-injector.ts +16 -25
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +276 -5
- package/src/shared/resource-discovery.ts +93 -6
- package/src/shared/xml-injection.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.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,16 +47,17 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"ajv": "^8.20.0",
|
|
49
49
|
"yaml": "^2.9.0",
|
|
50
|
-
"@xyz-agent/extension-protocol": "0.
|
|
51
|
-
"@
|
|
52
|
-
"@zhushanwen/pi-
|
|
50
|
+
"@xyz-agent/extension-protocol": "0.6.0",
|
|
51
|
+
"@xyz-agent/session-delivery": "0.2.0",
|
|
52
|
+
"@zhushanwen/pi-extension-logger": "0.3.0",
|
|
53
|
+
"@zhushanwen/pi-file-lock": "0.1.2"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
55
56
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
56
57
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
57
58
|
"@earendil-works/pi-tui": "^0.84.1",
|
|
58
59
|
"typebox": "*",
|
|
59
|
-
"@zhushanwen/pi-pending-notifications": "0.3.
|
|
60
|
+
"@zhushanwen/pi-pending-notifications": "0.3.5",
|
|
60
61
|
"@zhushanwen/pi-structured-output": "5.0.2"
|
|
61
62
|
},
|
|
62
63
|
"peerDependenciesMeta": {
|
|
@@ -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
|
-
|
|
38
|
+
let notifier: BgNotifier;
|
|
39
39
|
|
|
40
40
|
beforeEach(() => {
|
|
41
41
|
host = makeMockHost();
|
|
42
|
-
notifier =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
270
|
+
const notifier = createNotifier(host);
|
|
271
271
|
const sent: unknown[] = [];
|
|
272
272
|
(host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
|
|
273
273
|
|