@zhushanwen/pi-subagent-workflow 8.1.0 → 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 +12 -8
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +1 -1
- package/src/execution/__tests__/dialog-queue.test.ts +1 -1
- package/src/execution/__tests__/host-mode.test.ts +1 -1
- 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__/spawn-event-adapter-rpc.test.ts +1 -1
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-channels.test.ts +1 -1
- package/src/execution/__tests__/ui-interaction-model.test.ts +1 -1
- package/src/execution/__tests__/ui-request-observability.test.ts +1 -1
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-runner.ts +1 -1
- package/src/execution/subagent-service.ts +11 -4
- 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/__tests__/workflows-e2e.test.ts +2 -2
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/resource-discovery.ts +38 -0
- package/src/shared/xml-injection.ts +35 -0
- package/workflows/review-fix-loop-utils.cjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-subagent-workflow",
|
|
3
|
-
"version": "8.
|
|
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.",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"src/interface/",
|
|
31
31
|
"src/shared/"
|
|
32
32
|
],
|
|
33
|
+
"xyz-agent": {
|
|
34
|
+
"role": "universal"
|
|
35
|
+
},
|
|
33
36
|
"pi": {
|
|
34
37
|
"extensions": [
|
|
35
38
|
"./index.ts"
|
|
@@ -44,17 +47,18 @@
|
|
|
44
47
|
"dependencies": {
|
|
45
48
|
"ajv": "^8.20.0",
|
|
46
49
|
"yaml": "^2.9.0",
|
|
47
|
-
"@xyz-agent/extension-protocol": "0.
|
|
48
|
-
"@
|
|
50
|
+
"@xyz-agent/extension-protocol": "0.6.0",
|
|
51
|
+
"@xyz-agent/session-delivery": "0.2.0",
|
|
52
|
+
"@zhushanwen/pi-extension-logger": "0.2.2",
|
|
49
53
|
"@zhushanwen/pi-file-lock": "0.1.1"
|
|
50
54
|
},
|
|
51
55
|
"peerDependencies": {
|
|
52
|
-
"@earendil-works/pi-ai": "
|
|
53
|
-
"@earendil-works/pi-coding-agent": "
|
|
54
|
-
"@earendil-works/pi-tui": "
|
|
56
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.84.1",
|
|
55
59
|
"typebox": "*",
|
|
56
|
-
"@zhushanwen/pi-pending-notifications": "0.3.
|
|
57
|
-
"@zhushanwen/pi-structured-output": "5.0.
|
|
60
|
+
"@zhushanwen/pi-pending-notifications": "0.3.4",
|
|
61
|
+
"@zhushanwen/pi-structured-output": "5.0.2"
|
|
58
62
|
},
|
|
59
63
|
"peerDependenciesMeta": {
|
|
60
64
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -201,7 +201,7 @@ const results = await parallel(
|
|
|
201
201
|
);
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
-
> 内置通用编排 workflow(chain / parallel / scatter-gather / map-reduce,可直接 `workflow run`,用 `agent()` 自包含实现)见 `extensions/subagent-workflow/workflows/`。本段教 `workflow()` 嵌套 API,workflows 目录是开箱即用的通用编排工具(用 `agent()` 而非 `workflow()` 嵌套)。
|
|
204
|
+
> 内置通用编排 workflow(chain / parallel / scatter-gather / map-reduce,可直接 `workflow run`,用 `agent()` 自包含实现)见 `extensions/universal/subagent-workflow/workflows/`。本段教 `workflow()` 嵌套 API,workflows 目录是开箱即用的通用编排工具(用 `agent()` 而非 `workflow()` 嵌套)。
|
|
205
205
|
|
|
206
206
|
### Other globals
|
|
207
207
|
|
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 决策 D(修复 M4):channel handshake 协议升级测试。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/channel-registry-access.ts
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/channel-registry-access.ts
|
|
6
6
|
//
|
|
7
7
|
// 协议契约(与 ask-user 侧严格对齐):
|
|
8
8
|
// key 字面量: "@zhushanwen/pi-subagents.channelHandshake"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// W2 红灯测试:dialog-queue.ts — L2 跨子进程全局 dialog 串行队列。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/dialog-queue.ts(新建)
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/dialog-queue.ts(新建)
|
|
6
6
|
// 契约来源:.fix-plans/00-master-summary.md §一 冲突 3(L2 DialogGlobalQueue 设计)
|
|
7
7
|
//
|
|
8
8
|
// DialogGlobalQueue 设计要点:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// W1 红灯测试:host-mode.ts 主进程运行模式分类工具。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/host-mode.ts(新建)
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/host-mode.ts(新建)
|
|
6
6
|
// 契约来源:.fix-plans/00-master-summary.md §一 冲突 4 + §2.5(W4 守卫消费点)
|
|
7
7
|
//
|
|
8
8
|
// 三个导出:
|
|
@@ -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
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// W1 红灯测试:spawn-event-adapter.ts 协议层重写 — Pi 原生格式 + method 平铺。
|
|
4
4
|
//
|
|
5
|
-
// 测试对象:extensions/subagent-workflow/src/execution/spawn-event-adapter.ts(改造)
|
|
5
|
+
// 测试对象:extensions/universal/subagent-workflow/src/execution/spawn-event-adapter.ts(改造)
|
|
6
6
|
// 契约来源:.fix-plans/00-master-summary.md §二(统一接口契约)+ §一冲突 2/判定顺序
|
|
7
7
|
//
|
|
8
8
|
// 核心改动:
|