@zhushanwen/pi-session-manager 0.3.4 → 0.5.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 +8 -5
- package/skills/session-manager-ext-config/SKILL.md +24 -22
- package/src/__tests__/helpers/extension-harness.ts +5 -5
- package/src/__tests__/helpers/logger-mock.ts +5 -5
- package/src/__tests__/notify-content.test.ts +1 -1
- package/src/__tests__/notify-delivery-discipline.test.ts +125 -0
- package/src/__tests__/session-start-recovery.test.ts +25 -16
- package/src/__tests__/tool-summary.test.ts +169 -0
- package/src/__tests__/watch-orchestration.test.ts +120 -86
- package/src/index.ts +69 -17
- package/src/notify-content.ts +5 -5
- package/src/notify-delivery.ts +87 -0
- package/src/render.ts +22 -0
- package/src/watch-coordinator.ts +40 -17
- package/src/__tests__/notify-ledger-discipline.test.ts +0 -260
- package/src/notify-ledger.ts +0 -121
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Pi extension for managing agent-managed sessions — create, send, read history, list, status, abort via ctx.ui.select channel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -30,11 +30,10 @@
|
|
|
30
30
|
"README.md"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zhushanwen/extension-protocol": "0.
|
|
33
|
+
"@zhushanwen/extension-protocol": "0.22.0",
|
|
34
|
+
"@zhushanwen/pi-delivery-port": "0.2.0",
|
|
34
35
|
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
35
|
-
"@zhushanwen/pi-extension-logger": "0.6.2"
|
|
36
|
-
"@zhushanwen/pi-notify-ledger-host": "0.3.4",
|
|
37
|
-
"@zhushanwen/subagent-core": "1.5.2"
|
|
36
|
+
"@zhushanwen/pi-extension-logger": "0.6.2"
|
|
38
37
|
},
|
|
39
38
|
"devDependencies": {
|
|
40
39
|
"@types/node": "^24.0.0",
|
|
@@ -43,12 +42,16 @@
|
|
|
43
42
|
},
|
|
44
43
|
"peerDependencies": {
|
|
45
44
|
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
46
46
|
"typebox": "1.3.27"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@earendil-works/pi-coding-agent": {
|
|
50
50
|
"optional": true
|
|
51
51
|
},
|
|
52
|
+
"@earendil-works/pi-tui": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
52
55
|
"typebox": {
|
|
53
56
|
"optional": true
|
|
54
57
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: session-manager-ext-config
|
|
3
|
-
description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed session 六工具与完成通知)时加载。说明完成通知链(notifyId → watch 长挂通道 → managed-session-notify
|
|
3
|
+
description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed session 六工具与完成通知)时加载。说明完成通知链(notifyId → watch 长挂通道 → managed-session-notify 送达)、投递日志存储(delivery-port outbox/ack entry + session_start 差集重放)、pending 列表 type 'session' 条目的生命周期、willNotify 与 fulfills 合并语义、lifetimeNotifyId 死亡通知、未收到通知与 pending 残留的排查入口。触发词:session-manager、managed session、create_managed_session、send_to_session、完成通知没收到、通知未送达、notifyId、pending 列表有 session 条目、投递日志、delivery-port、managed-session-notify、willNotify、session-manager-ext-config。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# session-manager 使用与排查指南
|
|
@@ -8,7 +8,7 @@ description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed ses
|
|
|
8
8
|
> @zhushanwen/pi-session-manager:agent-managed session 扩展。父 session 通过 6 个工具
|
|
9
9
|
> (create_managed_session / send_to_session / read_session_history / list_my_sessions /
|
|
10
10
|
> get_session_status / abort_session)以子 session 形态并行工作。本指南讲完成通知链、
|
|
11
|
-
>
|
|
11
|
+
> 投递日志与排查入口;工具契约细节(marker 通道 / 超时分档)见包 README。
|
|
12
12
|
|
|
13
13
|
## 重要前提
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed ses
|
|
|
16
16
|
独立 pi CLI 无 handler 应答,所有工具等待至超时后报 `cancelled or timed out`。
|
|
17
17
|
工具失败时先确认运行环境,不要怀疑参数。
|
|
18
18
|
- 本扩展不读磁盘配置文件,无 config JSON——通知状态全部存储在 pi session 文件内
|
|
19
|
-
|
|
19
|
+
(见投递日志一节),没有「改配置文件」这条路径。
|
|
20
20
|
|
|
21
21
|
## 完成通知链(notifyId → watch → managed-session-notify)
|
|
22
22
|
|
|
@@ -32,9 +32,9 @@ description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed ses
|
|
|
32
32
|
`completed` / `failed` / `stopped`(结果新闻)、`exited` / `deleted`(死亡新闻)、
|
|
33
33
|
`cancelled` / `orphaned`(静默注销,不产出通知)。应答经 50ms trailing debounce 攒批:
|
|
34
34
|
分拣键 (sessionId, reason),批身份 settleSeq/deathSeq——同一批合并为一条通知。
|
|
35
|
-
4.
|
|
36
|
-
`
|
|
37
|
-
|
|
35
|
+
4. **入账投递**:攒批结果经 `recordManagedNotify` → delivery-port 的 `port.deliver`
|
|
36
|
+
提交(见下节),注入直达 pi 唯一队列(steer 档 + `triggerTurn: true`,pi 忙时入队
|
|
37
|
+
不拒、轮边界注入),送达回执按 notifyId 的 details-id 凭据匹配逐条处理完毕(ack entry)。
|
|
38
38
|
|
|
39
39
|
通知文案形态:`Managed session "<label>" (<sessionId>) finished with status "<status>"
|
|
40
40
|
(exit code: N). (fulfills N request(s))`;stderr 非空时附 Stderr tail(400 字截尾);
|
|
@@ -45,30 +45,32 @@ runtime 查得到时附 `Full transcript: <sessionFilePath>` 指针行。
|
|
|
45
45
|
- 多条 queued 消息在同一轮消费合并为一次通知(fulfills N = 本批兑现笔数);
|
|
46
46
|
- `create_managed_session` 还返回 `lifetimeNotifyId`:session 终局死亡(exited/deleted)
|
|
47
47
|
恒产出一次死亡通知,与结果通知相互独立;
|
|
48
|
-
- 每 notifyId
|
|
48
|
+
- 每 notifyId 至多一条通知(投递日志按 id 幂等判重),重放不重复发声。
|
|
49
49
|
|
|
50
|
-
##
|
|
50
|
+
## 投递日志(delivery-port)
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
通知记录与恢复不依赖独立磁盘文件,复用 pi session 文件本身(pi session append-only
|
|
53
|
+
契约:entry 只追加不删改,compaction 不清 entry):
|
|
53
54
|
|
|
54
|
-
- **
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
- **outbox entry = session 文件自定义 entry**:通知经 `@zhushanwen/pi-delivery-port`
|
|
56
|
+
的 `port.deliver` 提交(id = notifyId 幂等键,managed-session-notify 外部通道逐条),
|
|
57
|
+
受理前先 `pi.appendEntry` 写 outbox entry(delivery-port-outbox,plain custom entry
|
|
58
|
+
不进 LLM 上下文)——先落盘后投递,进程崩溃后债权不丢。
|
|
59
|
+
- **送达回执处理完毕**:注入后 pi 侧出现携带该 id 的 custom_message,port 订阅
|
|
60
|
+
message_end 按 details-id 凭据命中 → 写 ack entry(delivery-port-ack)终局。
|
|
61
|
+
- **session_start 差集重放**:每次 session_start 扫投递日志 −(对话记录已送达 ∪ ack)
|
|
62
|
+
差集——未处理的债权重新投递,已处理完毕的静默;按 notifyId 幂等去重,重放不重复发声。
|
|
63
|
+
- 投递端口装配在 `@zhushanwen/pi-base-tool-enhance` 的 session_start(infrastructure
|
|
64
|
+
档必载),本扩展零装配义务——port 恒可用;缺席仅覆盖装配异常窗口(warn + 弃投该条,
|
|
65
|
+
下次 session_start 重试装配)。
|
|
64
66
|
|
|
65
67
|
## 排查入口
|
|
66
68
|
|
|
67
69
|
| 症状 | 排查路径 |
|
|
68
70
|
|------|---------|
|
|
69
|
-
| 未收到完成通知 | ① 确认 taiji 桌面环境(standalone pi CLI 无 handler,工具本身就会超时);② 查扩展日志 `<agentDir>/logs/session-manager-YYYY-MM-DD.log`(agentDir = pi `getAgentDir()`,默认 `~/.pi/agent`,taiji 隔离环境 `~/.taiji/agent`)搜 `[session-manager]`——`
|
|
70
|
-
| pending 列表残留 type 'session' 条目 | register 由 send/create 带 prompt 的 arm 写入,正常由 watch 应答逐条 unregister。残留 = watch 应答未达(pi 进程被杀 / runtime 重启 / 通道折叠 cancelled);这些活跃 register 由下次 session_start
|
|
71
|
-
| 同一完成重复通知 |
|
|
71
|
+
| 未收到完成通知 | ① 确认 taiji 桌面环境(standalone pi CLI 无 handler,工具本身就会超时);② 查扩展日志 `<agentDir>/logs/session-manager-YYYY-MM-DD.log`(agentDir = pi `getAgentDir()`,默认 `~/.pi/agent`,taiji 隔离环境 `~/.taiji/agent`)搜 `[session-manager]`——`delivery port not bound`(base-tool-enhance session_start 未跑/装配失败,通知丢弃,下次 session_start 重试装配)、`delivery port rejected notification`(受理被拒:幂等键未登记,watch 重试合法)、`watch select rejected`(watch 通道被拒)、`watch respond unusable/malformed`(runtime 应答不可解析,协议漂移信号);③ 日志干净则确认子 session 是否真完成(get_session_status) |
|
|
72
|
+
| pending 列表残留 type 'session' 条目 | register 由 send/create 带 prompt 的 arm 写入,正常由 watch 应答逐条 unregister。残留 = watch 应答未达(pi 进程被杀 / runtime 重启 / 通道折叠 cancelled);这些活跃 register 由下次 session_start 收尾路径逐条重开 watch(入参 = entry id 即 notifyId),应答到达即静默注销,无需手工处理 |
|
|
73
|
+
| 同一完成重复通知 | 不应发生——投递日志按 notifyId 判重、死亡槽 (sessionId, deathSeq) 同键只发首条。若复现即协议漂移,查扩展日志 `delivery port rejected notification` / `notify delivery failed` 留痕归因 |
|
|
72
74
|
|
|
73
75
|
## 工具速查
|
|
74
76
|
|
|
@@ -13,7 +13,7 @@ import { expect, vi, type Mock } from "vitest";
|
|
|
13
13
|
import registerExtension from "../../index.ts";
|
|
14
14
|
|
|
15
15
|
/** registerTool 捕获的注册工具形态(execute 以六工具用例的固定五参形态调用) */
|
|
16
|
-
export interface RegisteredTool { // oe-exempt:20260930:test:测试 harness
|
|
16
|
+
export interface RegisteredTool { // oe-exempt:20260930:test:测试 harness 契约——多测试文件的断言范围引用其字段类型
|
|
17
17
|
name: string;
|
|
18
18
|
execute: (...args: unknown[]) => Promise<unknown>;
|
|
19
19
|
}
|
|
@@ -26,7 +26,7 @@ export interface EmittedEvent { // oe-exempt:20260930:test:事件断言形状契
|
|
|
26
26
|
export interface ExtensionHarness {
|
|
27
27
|
registered: RegisteredTool[];
|
|
28
28
|
emitted: EmittedEvent[];
|
|
29
|
-
/** pi.on 注册的 handler(session_start / session_compact
|
|
29
|
+
/** pi.on 注册的 handler(session_start / session_compact 收尾路径触发点) */
|
|
30
30
|
handlers: Record<string, (event: unknown, ctx: unknown) => unknown>;
|
|
31
31
|
/** rpc 模式 ctx 桩(sessionManager.getEntries 读 mountExtension 的 entries 入参) */
|
|
32
32
|
ctx: Record<string, unknown>;
|
|
@@ -75,7 +75,7 @@ export function createToolHarness(selectImpl: (...args: unknown[]) => Promise<un
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** 一次 watch 开表调用(deferred——由测试按调用序应答) */
|
|
78
|
-
export interface WatchInvocation { // oe-exempt:20260930:test:watch
|
|
78
|
+
export interface WatchInvocation { // oe-exempt:20260930:test:watch 调用记录形状——断言范围契约
|
|
79
79
|
notifyId: string;
|
|
80
80
|
resolve: (value: unknown) => void;
|
|
81
81
|
}
|
|
@@ -94,8 +94,8 @@ export async function flushMicrotasks(): Promise<void> {
|
|
|
94
94
|
for (let i = 0; i < 12; i++) await Promise.resolve();
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** 工具文本结果形态(六工具 execute 成功路径的 content
|
|
98
|
-
export interface ToolTextResult { // oe-exempt:20260930:test:runToolForText
|
|
97
|
+
/** 工具文本结果形态(六工具 execute 成功路径的 content 派生视图) */
|
|
98
|
+
export interface ToolTextResult { // oe-exempt:20260930:test:runToolForText 返回契约——断言范围消费
|
|
99
99
|
content: Array<{ type: string; text: string }>;
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// logger-mock.ts — session-manager 测试共享的 pi-extension-logger 模块 mock
|
|
2
|
-
//(notify-
|
|
2
|
+
//(notify-delivery-discipline / tool-non-json-response / watch-orchestration 三份
|
|
3
3
|
// 逐字重复的 vi.hoisted + vi.mock 样板提取)。
|
|
4
4
|
//
|
|
5
5
|
// 使用方式(测试文件内):
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
// 初始化顺序约束(同 subagent-workflow injector-test-mocks 先例):本文件只有
|
|
11
11
|
// vitest 一个运行时依赖、不 import 被测模块——vi.mock 工厂惰性执行时引用本文件的
|
|
12
12
|
// import 绑定,故测试文件里本 import 必须先于任何会加载 pi-extension-logger 的
|
|
13
|
-
// import(../index.ts / ../notify-
|
|
13
|
+
// import(../index.ts / ../notify-delivery.ts / extension-harness helper)。
|
|
14
14
|
// 模块级单例按测试文件隔离(vitest 默认 isolate),getLoggerMock() 与被 mock 模块
|
|
15
|
-
// 的 getLogger()
|
|
15
|
+
// 的 getLogger() 返回同一引用——断言范围与桩面同源。
|
|
16
16
|
|
|
17
17
|
import { vi } from "vitest";
|
|
18
18
|
|
|
19
|
-
/** 与被 mock 模块 getLogger() 同源的 spy
|
|
19
|
+
/** 与被 mock 模块 getLogger() 同源的 spy 集(断言范围经 getLoggerMock() 取用) */
|
|
20
20
|
const loggerMock = {
|
|
21
21
|
error: vi.fn(),
|
|
22
22
|
warn: vi.fn(),
|
|
@@ -25,7 +25,7 @@ const loggerMock = {
|
|
|
25
25
|
|
|
26
26
|
export type LoggerMock = typeof loggerMock;
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/** 断言范围:取本测试文件的 logger spy(与 mock 模块内 getLogger() 返回值同引用) */
|
|
29
29
|
export function getLoggerMock(): LoggerMock {
|
|
30
30
|
return loggerMock;
|
|
31
31
|
}
|
|
@@ -52,7 +52,7 @@ describe("buildManagedNotifyContent(D9 文案形状)", () => {
|
|
|
52
52
|
expect(absent).not.toContain("fulfills");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it("exit code 注入 status 段内(death 文案,exit
|
|
55
|
+
it("exit code 注入 status 段内(death 文案,exit 路径诊断通路复刻)", () => {
|
|
56
56
|
const content = buildManagedNotifyContent({
|
|
57
57
|
label: "L",
|
|
58
58
|
sessionId: "s1",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// notify-delivery-discipline.test.ts — recordManagedNotify 的投递接入纪律
|
|
2
|
+
// (unified-delivery 波 2:投递路径切 pi 半区投递端口后的消费纪律锁)。
|
|
3
|
+
//
|
|
4
|
+
// 覆盖(原 notify-ledger-discipline.test.ts 场景的 port 形态对位——账本装配纪律
|
|
5
|
+
// ①②③④与 stale 检查随账本退役:bind/recover 收敛由 pi-delivery-port 套件的
|
|
6
|
+
// 「重复 bind 替换并 dispose 旧实例」锁定,stale 注入 throw → 内核 retain 由其
|
|
7
|
+
// 「注入 throw → retain 留账」锁定,compaction 接线随 append-only 契约裁决删除):
|
|
8
|
+
// ⑤ 消费点经 getDeliveryPort() 动态查槽(禁缓存实例引用——后 bind 方 dispose
|
|
9
|
+
// 旧实例后缓存引用的 deliver 静默 rejected,对半概率丢全部通知的 D3⑤ 同律);
|
|
10
|
+
// 通道 = managed-session-notify 外部通道(payload.customType,port 批组隔离恒
|
|
11
|
+
// 逐条不合批);槽空降级(port 缺席 → warn + false,STANDARDS §11.1);port
|
|
12
|
+
// rejected(persist-failed)→ warn + false(幂等键未登记,watch 重试合法)。
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
|
|
16
|
+
|
|
17
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
|
|
18
|
+
|
|
19
|
+
const loggerMock = getLoggerMock();
|
|
20
|
+
|
|
21
|
+
import { recordManagedNotify } from "../notify-delivery.ts";
|
|
22
|
+
import type { ManagedNotifyDetails } from "../notify-content.ts";
|
|
23
|
+
import { DELIVERY_PORT_SLOT_KEY } from "@zhushanwen/pi-delivery-port";
|
|
24
|
+
|
|
25
|
+
// pi-delivery-port 进程级槽(C-ext-06;recordManagedNotify 的消费入口)。
|
|
26
|
+
const PORT_SLOT_KEY = DELIVERY_PORT_SLOT_KEY;
|
|
27
|
+
|
|
28
|
+
/** recordManagedNotify 的最小合法 details(ManagedNotifyDetails 收紧后的测试桩形状)。 */
|
|
29
|
+
function makeDetails(notifyId: string): ManagedNotifyDetails {
|
|
30
|
+
return { notifyId, sessionId: "s-test", reason: "completed", fulfills: 1, label: "test" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type DeliverCall = {
|
|
34
|
+
id: string | undefined;
|
|
35
|
+
customType: string;
|
|
36
|
+
content: string;
|
|
37
|
+
details: unknown;
|
|
38
|
+
collectKey: string[] | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function plantPort(behavior: "accept" | "reject" = "accept"): Array<DeliverCall> {
|
|
42
|
+
const calls: Array<DeliverCall> = [];
|
|
43
|
+
Reflect.set(globalThis, PORT_SLOT_KEY, {
|
|
44
|
+
current: {
|
|
45
|
+
deliver: (
|
|
46
|
+
envelope: {
|
|
47
|
+
id?: string;
|
|
48
|
+
payload: { customType: string; content: string; details?: unknown };
|
|
49
|
+
},
|
|
50
|
+
opts?: { collectKey?: string[] },
|
|
51
|
+
) => {
|
|
52
|
+
if (behavior === "reject") return { kind: "rejected", reason: "persist-failed (fake)" };
|
|
53
|
+
calls.push({
|
|
54
|
+
id: envelope.id,
|
|
55
|
+
customType: envelope.payload.customType,
|
|
56
|
+
content: envelope.payload.content,
|
|
57
|
+
details: envelope.payload.details,
|
|
58
|
+
collectKey: opts?.collectKey,
|
|
59
|
+
});
|
|
60
|
+
return { kind: "accepted", id: envelope.id ?? "gen-id" };
|
|
61
|
+
},
|
|
62
|
+
flush: () => {},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return calls;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
loggerMock.warn.mockClear();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
// 摘除槽注入——防泄漏到同文件后续用例(生产槽由 base-tool-enhance session_start 装配)。
|
|
74
|
+
Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("recordManagedNotify 投递接入纪律(unified-delivery 波 2:port 消费方)", () => {
|
|
78
|
+
it("槽空(port 未装配)→ 降级 false + warn 留痕(通知类接入点降级,非静默吞)", () => {
|
|
79
|
+
Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
|
|
80
|
+
expect(recordManagedNotify("sm-x", "content", makeDetails("sm-x"), ["sm-x"])).toBe(false);
|
|
81
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
82
|
+
expect.stringContaining("delivery port not bound"),
|
|
83
|
+
expect.objectContaining({ notifyId: "sm-x" }),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("port 在 → 动态查槽投递:id = notifyId、payload.customType = managed-session-notify(外部通道)、details 原样、collectKey 键集透传", () => {
|
|
88
|
+
const delivered = plantPort();
|
|
89
|
+
const details = makeDetails("sm-a");
|
|
90
|
+
expect(recordManagedNotify("sm-a", "hello", details, ["sm-a", "sm-b"])).toBe(true);
|
|
91
|
+
expect(delivered).toHaveLength(1);
|
|
92
|
+
expect(delivered[0]!.id).toBe("sm-a");
|
|
93
|
+
expect(delivered[0]!.customType).toBe("managed-session-notify");
|
|
94
|
+
expect(delivered[0]!.content).toBe("hello");
|
|
95
|
+
expect(delivered[0]!.details).toBe(details);
|
|
96
|
+
// collectKey 债权键集申报(result-collect 设计 §3.4):批内全部债权键原样透传
|
|
97
|
+
expect(delivered[0]!.collectKey).toEqual(["sm-a", "sm-b"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("port rejected(persist-failed)→ warn + false(幂等键未登记,watch 重试合法;不静默吞)", () => {
|
|
101
|
+
plantPort("reject");
|
|
102
|
+
expect(recordManagedNotify("sm-rej", "content", makeDetails("sm-rej"), ["sm-rej"])).toBe(false);
|
|
103
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
104
|
+
expect.stringContaining("delivery port rejected notification"),
|
|
105
|
+
expect.objectContaining({ notifyId: "sm-rej" }),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("投递异常(port.deliver 抛错)→ warn 留痕不外抛(不拖垮 watch 应答编排,unregister 照常)", () => {
|
|
110
|
+
Reflect.set(globalThis, PORT_SLOT_KEY, {
|
|
111
|
+
current: {
|
|
112
|
+
deliver: () => {
|
|
113
|
+
throw new Error("session context is no longer active (assertActive)");
|
|
114
|
+
},
|
|
115
|
+
flush: () => {},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
expect(() => recordManagedNotify("sm-throw", "content", makeDetails("sm-throw"), ["sm-throw"])).not.toThrow();
|
|
119
|
+
expect(recordManagedNotify("sm-throw-2", "content", makeDetails("sm-throw-2"), ["sm-throw-2"])).toBe(false);
|
|
120
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
121
|
+
expect.stringContaining("notify delivery failed"),
|
|
122
|
+
expect.objectContaining({ notifyId: "sm-throw-2" }),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// session-start-recovery.test.ts —
|
|
2
|
-
//
|
|
1
|
+
// session-start-recovery.test.ts — 重启收尾路径(notify-once D7③ / U4)。
|
|
2
|
+
// 断言范围:活跃 type 'session' register 重开 watch(入参 = entry id 即 notifyId)/
|
|
3
3
|
// 类型过滤(workflow 与已注销条目不触碰)/ 覆盖序(W1 悬置被 W2 覆盖后该 entry 恰一次
|
|
4
4
|
// unregister、后续 entry 不受阻塞——每 entry 独立 handler 禁串行 await)/
|
|
5
|
-
//
|
|
5
|
+
// 收尾路径 label 降级源 = register 三键 name。
|
|
6
6
|
// pi/ctx 装配、respondWatch、flushMicrotasks 走共享 harness(helpers/extension-harness.ts);
|
|
7
7
|
// watch-only select(非 watch action 即抛)为本文件域行为,留在本地。
|
|
8
8
|
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { DELIVERY_PORT_SLOT_KEY } from "@zhushanwen/pi-delivery-port";
|
|
10
11
|
import {
|
|
11
12
|
flushMicrotasks,
|
|
12
13
|
makeRespondWatch,
|
|
@@ -15,7 +16,8 @@ import {
|
|
|
15
16
|
type WatchInvocation,
|
|
16
17
|
} from "./helpers/extension-harness.ts";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// pi-delivery-port 进程级槽(recordManagedNotify 的投递观测点——unified-delivery 波 2)。
|
|
20
|
+
const PORT_SLOT_KEY = DELIVERY_PORT_SLOT_KEY;
|
|
19
21
|
|
|
20
22
|
interface Harness extends ExtensionHarness {
|
|
21
23
|
watches: WatchInvocation[];
|
|
@@ -31,7 +33,7 @@ function staleEntries(): unknown[] {
|
|
|
31
33
|
return [
|
|
32
34
|
{ customType: "pending:register", data: { id: E1, type: "session", name: "Alpha", registeredAt: 1, sessionId: "parent-1" } },
|
|
33
35
|
{ customType: "pending:register", data: { id: E2, type: "session", name: "Beta", registeredAt: 2, sessionId: "parent-1" } },
|
|
34
|
-
// workflow 类型归 core 对账 sweep
|
|
36
|
+
// workflow 类型归 core 对账 sweep,收尾路径不得触碰
|
|
35
37
|
{ customType: "pending:register", data: { id: WF, type: "workflow", name: "run-1" } },
|
|
36
38
|
// 已注销(差集净空)→ 不重开
|
|
37
39
|
{ customType: "pending:register", data: { id: E3, type: "session", name: "Gamma", registeredAt: 3, sessionId: "parent-1" } },
|
|
@@ -64,7 +66,7 @@ function unregistersOf(h: Harness): Array<[string, unknown]> {
|
|
|
64
66
|
.map((e) => [String(e.data.id), e.data.reason]);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
/** 单条 session register
|
|
69
|
+
/** 单条 session register 的收尾路径驱动(fail-closed / label 降级两用例共用前置):
|
|
68
70
|
* 开表(session_start)→ 排干微任务 → 应答首个 watch → 再排干 */
|
|
69
71
|
async function startSingleEntrySession(respondValue: unknown): Promise<Harness> {
|
|
70
72
|
const h = createHarness([
|
|
@@ -83,10 +85,10 @@ beforeEach(() => {
|
|
|
83
85
|
|
|
84
86
|
afterEach(() => {
|
|
85
87
|
vi.useRealTimers();
|
|
86
|
-
Reflect.deleteProperty(globalThis,
|
|
88
|
+
Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
|
|
87
89
|
});
|
|
88
90
|
|
|
89
|
-
describe("session_start
|
|
91
|
+
describe("session_start 重启收尾路径(D7③)", () => {
|
|
90
92
|
it("重开 watch 覆盖序:W1 悬置被 W2 覆盖后该 entry 恰一次 unregister,且后续 entry 不受阻塞", async () => {
|
|
91
93
|
const h = createHarness(staleEntries());
|
|
92
94
|
expect(h.handlers.session_start).toBeTypeOf("function");
|
|
@@ -97,7 +99,7 @@ describe("session_start 重启收口腿(D7③)", () => {
|
|
|
97
99
|
await flushMicrotasks();
|
|
98
100
|
expect(h.watches.map((w) => w.notifyId)).toEqual([E1, E2]);
|
|
99
101
|
|
|
100
|
-
// E2 先应答 orphaned
|
|
102
|
+
// E2 先应答 orphaned(杀父路径,触发器②批量转移先行)→ 即时静默 unregister
|
|
101
103
|
h.respondWatch(1, JSON.stringify({ reason: "orphaned", sessionId: "child-b" }));
|
|
102
104
|
await flushMicrotasks();
|
|
103
105
|
expect(unregistersOf(h)).toEqual([[E2, "orphaned"]]);
|
|
@@ -125,21 +127,28 @@ describe("session_start 重启收口腿(D7③)", () => {
|
|
|
125
127
|
expect(watchedIds).not.toContain(E3);
|
|
126
128
|
});
|
|
127
129
|
|
|
128
|
-
it("runtime 已重启查无 claim → fail-closed 'cancelled'
|
|
130
|
+
it("runtime 已重启查无 claim → fail-closed 'cancelled' 路径:静默注销(活跃集回归基线)", async () => {
|
|
129
131
|
const h = await startSingleEntrySession(JSON.stringify({ reason: "cancelled" }));
|
|
130
132
|
expect(unregistersOf(h)).toEqual([[E1, "cancelled"]]);
|
|
131
133
|
});
|
|
132
134
|
|
|
133
|
-
it("
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
it("收尾路径 label 降级源 = register 三键 name(D9 label 两路径的恢复路径)", async () => {
|
|
136
|
+
const delivered: Array<{ id?: string; content: string }> = [];
|
|
137
|
+
Reflect.set(globalThis, PORT_SLOT_KEY, {
|
|
138
|
+
current: {
|
|
139
|
+
deliver: (envelope: { id?: string; payload: { content: string } }) => {
|
|
140
|
+
delivered.push({ id: envelope.id, content: envelope.payload.content });
|
|
141
|
+
return { kind: "accepted", id: envelope.id ?? "gen-id" };
|
|
142
|
+
},
|
|
143
|
+
flush: () => {},
|
|
144
|
+
},
|
|
136
145
|
});
|
|
137
|
-
const record = Reflect.get(globalThis, LEDGER_SLOT_KEY).current.record;
|
|
138
146
|
const h = await startSingleEntrySession(
|
|
139
147
|
JSON.stringify({ reason: "completed", sessionId: "child-x", settleSeq: 1, fulfillsN: 1 }),
|
|
140
148
|
);
|
|
141
149
|
await vi.advanceTimersByTimeAsync(50);
|
|
142
|
-
expect(
|
|
143
|
-
expect(
|
|
150
|
+
expect(delivered).toHaveLength(1);
|
|
151
|
+
expect(delivered[0]!.content).toContain('Managed session "Alpha" (child-x)');
|
|
152
|
+
Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
|
|
144
153
|
});
|
|
145
154
|
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// tool-summary.test.ts — details.summary 产出(六工具文案断言,走 execute 骨架)+
|
|
2
|
+
// renderSessionManagerResult 两态(折叠态一行摘要 / 展开态原始 content 文本)。
|
|
3
|
+
|
|
4
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
6
|
+
import registerExtension from "../index.ts";
|
|
7
|
+
import { renderSessionManagerResult } from "../render.ts";
|
|
8
|
+
|
|
9
|
+
/** Capture registered tools and provide a mock ctx.ui.select. */
|
|
10
|
+
function createHarness() {
|
|
11
|
+
const registered: Array<{ name: string; execute: Function; renderResult?: Function }> = [];
|
|
12
|
+
const selectMock = vi.fn().mockResolvedValue('{"ok":true}');
|
|
13
|
+
const pi = {
|
|
14
|
+
registerTool: (tool: { name: string; execute: Function; renderResult?: Function }) => registered.push(tool),
|
|
15
|
+
on: vi.fn(),
|
|
16
|
+
getAllTools: vi.fn(() => []),
|
|
17
|
+
setActiveTools: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
const ctx = {
|
|
20
|
+
mode: "rpc" as const,
|
|
21
|
+
hasUI: true,
|
|
22
|
+
ui: { select: selectMock },
|
|
23
|
+
};
|
|
24
|
+
registerExtension(pi as never);
|
|
25
|
+
return { registered, selectMock, ctx };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// mock theme(fg 直通,便于断言纯文本;todo render.test.ts 同款)
|
|
29
|
+
const mockTheme = {
|
|
30
|
+
fg: (_color: string, text: string) => text,
|
|
31
|
+
bg: (_color: string, text: string) => text,
|
|
32
|
+
bold: (text: string) => text,
|
|
33
|
+
} as unknown as Theme;
|
|
34
|
+
|
|
35
|
+
function renderedText(result: unknown, expanded: boolean): string {
|
|
36
|
+
return renderSessionManagerResult(result, { expanded }, mockTheme).render(400).join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("execute 组装 details.summary(六工具文案)", () => {
|
|
40
|
+
let harness: ReturnType<typeof createHarness>;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
harness = createHarness();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function summaryOf(toolName: string, params: Record<string, unknown>, reply: unknown): Promise<string> {
|
|
47
|
+
harness.selectMock.mockResolvedValue(JSON.stringify(reply));
|
|
48
|
+
const tool = harness.registered.find((t) => t.name === toolName)!;
|
|
49
|
+
const result = (await tool.execute("call-1", params, undefined, undefined, harness.ctx)) as {
|
|
50
|
+
details: { summary: string };
|
|
51
|
+
};
|
|
52
|
+
return result.details.summary;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it("create_managed_session:带 label → 引号 label;无 label → 回退 sessionId", async () => {
|
|
56
|
+
expect(
|
|
57
|
+
await summaryOf("create_managed_session", { cwd: "/work", label: "worker" }, { sessionId: "s1", status: "created" }),
|
|
58
|
+
).toBe('Created session "worker"');
|
|
59
|
+
expect(
|
|
60
|
+
await summaryOf("create_managed_session", { cwd: "/work" }, { sessionId: "s1", status: "created" }),
|
|
61
|
+
).toBe("Created session s1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("send_to_session:Sent message to <sessionId>", async () => {
|
|
65
|
+
expect(await summaryOf("send_to_session", { sessionId: "s1", prompt: "hi" }, { queued: true, willNotify: true })).toBe(
|
|
66
|
+
"Sent message to s1",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("read_session_history:messages 计数后缀", async () => {
|
|
71
|
+
expect(
|
|
72
|
+
await summaryOf("read_session_history", { sessionId: "s1" }, { messages: [{}, {}, {}], truncated: false }),
|
|
73
|
+
).toBe("Read history of s1 · 3 entries");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("list_my_sessions:sessions 计数", async () => {
|
|
77
|
+
expect(await summaryOf("list_my_sessions", {}, { sessions: [{ id: "s1" }, { id: "s2" }] })).toBe(
|
|
78
|
+
"Listed 2 managed sessions",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("get_session_status:<sessionId> · <status>", async () => {
|
|
83
|
+
expect(await summaryOf("get_session_status", { sessionId: "s1" }, { status: "idle", undeliveredResults: 0 })).toBe(
|
|
84
|
+
"s1 · idle",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("abort_session:Aborted <sessionId>", async () => {
|
|
89
|
+
expect(await summaryOf("abort_session", { sessionId: "s1" }, { success: true })).toBe("Aborted s1");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("toSummary 数据缺失分支(形状漂移退化为短文案,不抛错)", () => {
|
|
94
|
+
let harness: ReturnType<typeof createHarness>;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
harness = createHarness();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function summaryOf(toolName: string, params: Record<string, unknown>, reply: unknown): Promise<string> {
|
|
101
|
+
harness.selectMock.mockResolvedValue(JSON.stringify(reply));
|
|
102
|
+
const tool = harness.registered.find((t) => t.name === toolName)!;
|
|
103
|
+
const result = (await tool.execute("call-1", params, undefined, undefined, harness.ctx)) as {
|
|
104
|
+
details: { summary: string };
|
|
105
|
+
};
|
|
106
|
+
return result.details.summary;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it("create_managed_session:label 与 sessionId 均缺 → 裸文案", async () => {
|
|
110
|
+
expect(await summaryOf("create_managed_session", { cwd: "/work" }, {})).toBe("Created session");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("read_session_history:messages 缺失/非数组 → 无计数后缀", async () => {
|
|
114
|
+
expect(await summaryOf("read_session_history", { sessionId: "s1" }, { truncated: false })).toBe("Read history of s1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("list_my_sessions:sessions 非数组 → 无计数", async () => {
|
|
118
|
+
expect(await summaryOf("list_my_sessions", {}, { sessions: "bad" })).toBe("Listed managed sessions");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("get_session_status:status 缺失 → Status of <sessionId>", async () => {
|
|
122
|
+
expect(await summaryOf("get_session_status", { sessionId: "s1" }, {})).toBe("Status of s1");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("renderSessionManagerResult 两态", () => {
|
|
127
|
+
it("折叠态(expanded=false):渲染 details.summary 一行摘要,不含原始 JSON", () => {
|
|
128
|
+
const result = {
|
|
129
|
+
content: [{ type: "text", text: '{"messages":[{}]}' }],
|
|
130
|
+
details: { summary: "Read history of s1 · 1 entries" },
|
|
131
|
+
};
|
|
132
|
+
const out = renderedText(result, false);
|
|
133
|
+
expect(out).toContain("Read history of s1 · 1 entries");
|
|
134
|
+
expect(out).not.toContain("messages");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("展开态(expanded=true):渲染 content[0] 原始文本,不含摘要", () => {
|
|
138
|
+
const result = {
|
|
139
|
+
content: [{ type: "text", text: '{"messages":[{}]}' }],
|
|
140
|
+
details: { summary: "Read history of s1 · 1 entries" },
|
|
141
|
+
};
|
|
142
|
+
const out = renderedText(result, true);
|
|
143
|
+
expect(out).toContain('{"messages":[{}]}');
|
|
144
|
+
expect(out).not.toContain("Read history of s1");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("折叠态但 details 缺失 / summary 非字符串 → 回退 content 文本", () => {
|
|
148
|
+
expect(renderedText({ content: [{ type: "text", text: "raw" }] }, false)).toContain("raw");
|
|
149
|
+
expect(renderedText({ content: [{ type: "text", text: "raw" }], details: { summary: 7 } }, false)).toContain("raw");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("六工具注册的 renderResult 均存在且折叠态行为一致(共用渲染内核)", () => {
|
|
153
|
+
const harness = createHarness();
|
|
154
|
+
const renderFns = harness.registered.map((t) => t.renderResult);
|
|
155
|
+
expect(renderFns).toHaveLength(6);
|
|
156
|
+
for (const fn of renderFns) expect(typeof fn).toBe("function");
|
|
157
|
+
// 抽查一个 handler:折叠态出摘要、不出原始 JSON(handler 是各 registerTool
|
|
158
|
+
// 的方法包装,内核同为 renderSessionManagerResult)
|
|
159
|
+
const out = (renderFns[0] as (r: unknown, o: { expanded: boolean }, t: unknown) => { render: (w: number) => string[] })(
|
|
160
|
+
{ content: [{ type: "text", text: "raw" }], details: { summary: "Aborted s1" } },
|
|
161
|
+
{ expanded: false },
|
|
162
|
+
mockTheme,
|
|
163
|
+
)
|
|
164
|
+
.render(400)
|
|
165
|
+
.join("\n");
|
|
166
|
+
expect(out).toContain("Aborted s1");
|
|
167
|
+
expect(out).not.toContain("raw");
|
|
168
|
+
});
|
|
169
|
+
});
|