@zhushanwen/pi-session-manager 0.1.15 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -3
- package/skills/session-manager-ext-config/SKILL.md +82 -0
- package/src/__tests__/helpers/extension-harness.ts +137 -0
- package/src/__tests__/helpers/logger-mock.ts +39 -0
- package/src/__tests__/notify-content.test.ts +108 -0
- package/src/__tests__/notify-ledger-discipline.test.ts +260 -0
- package/src/__tests__/session-start-recovery.test.ts +145 -0
- package/src/__tests__/tool-error-handling.test.ts +57 -63
- package/src/__tests__/tool-non-json-response.test.ts +34 -49
- package/src/__tests__/tool-schema.test.ts +10 -9
- package/src/__tests__/watch-orchestration.test.ts +563 -0
- package/src/index.ts +120 -25
- package/src/notify-content.ts +77 -0
- package/src/notify-ledger.ts +121 -0
- package/src/watch-coordinator.ts +336 -0
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
7
7
|
"pi": {
|
|
8
8
|
"extensions": [
|
|
9
9
|
"./index.ts"
|
|
10
|
+
],
|
|
11
|
+
"skills": [
|
|
12
|
+
"./skills"
|
|
10
13
|
]
|
|
11
14
|
},
|
|
12
15
|
"taiji": {
|
|
@@ -23,11 +26,15 @@
|
|
|
23
26
|
"files": [
|
|
24
27
|
"index.ts",
|
|
25
28
|
"src/",
|
|
29
|
+
"skills/",
|
|
26
30
|
"README.md"
|
|
27
31
|
],
|
|
28
32
|
"dependencies": {
|
|
29
|
-
"@zhushanwen/extension-protocol": "0.
|
|
30
|
-
"@zhushanwen/pi-
|
|
33
|
+
"@zhushanwen/extension-protocol": "0.16.0",
|
|
34
|
+
"@zhushanwen/pi-ext-guards": "0.4.2",
|
|
35
|
+
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
36
|
+
"@zhushanwen/pi-notify-ledger-host": "0.2.0",
|
|
37
|
+
"@zhushanwen/subagent-core": "0.13.0"
|
|
31
38
|
},
|
|
32
39
|
"devDependencies": {
|
|
33
40
|
"@types/node": "^24.0.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: session-manager-ext-config
|
|
3
|
+
description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed session 六工具与完成通知)时加载。说明完成通知链(notifyId → watch 长挂通道 → managed-session-notify 送达)、B-ledger 账本存储(session 文件自定义 entry + session_start 重装恢复 + session_compact 补写)、pending 列表 type 'session' 条目的生命周期、willNotify 与 fulfills 合并语义、lifetimeNotifyId 死亡通知、未收到通知与 pending 残留的排查入口。触发词:session-manager、managed session、create_managed_session、send_to_session、完成通知没收到、通知未送达、notifyId、pending 列表有 session 条目、B-ledger、managed-session-notify、willNotify、session-manager-ext-config。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# session-manager 使用与排查指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-session-manager:agent-managed session 扩展。父 session 通过 6 个工具
|
|
9
|
+
> (create_managed_session / send_to_session / read_session_history / list_my_sessions /
|
|
10
|
+
> get_session_status / abort_session)以子 session 形态并行工作。本指南讲完成通知链、
|
|
11
|
+
> 账本存储与排查入口;工具契约细节(marker 通道 / 超时分档)见包 README。
|
|
12
|
+
|
|
13
|
+
## 重要前提
|
|
14
|
+
|
|
15
|
+
- 工具应答端是 taiji runtime 的 SessionManagerHandler——**只在 taiji 桌面环境可用**;
|
|
16
|
+
独立 pi CLI 无 handler 应答,所有工具等待至超时后报 `cancelled or timed out`。
|
|
17
|
+
工具失败时先确认运行环境,不要怀疑参数。
|
|
18
|
+
- 本扩展不读磁盘配置文件,无 config JSON——通知状态全部存储在 pi session 文件内
|
|
19
|
+
(见 B-ledger 一节),没有「改配置文件」这条路径。
|
|
20
|
+
|
|
21
|
+
## 完成通知链(notifyId → watch → managed-session-notify)
|
|
22
|
+
|
|
23
|
+
一次带完成通知的工作流分四步:
|
|
24
|
+
|
|
25
|
+
1. **生成债权**:`create_managed_session` 带 `prompt` 时(或每次 `send_to_session`)生成一个
|
|
26
|
+
notifyId(`sm-` 前缀 + UUID)。runtime 返回 `willNotify: true` 表示该次调用已受理一笔
|
|
27
|
+
完成通知债权;`willNotify: false`(create 不带 prompt)不产生通知。
|
|
28
|
+
2. **arm**:工具结果成功后扩展做两件事——emit `pending:register`({id = notifyId,
|
|
29
|
+
type: 'session', name = label},由 pending-notifications 扩展消费落 pending 列表)+
|
|
30
|
+
开 watch(fire-and-forget,不传 timeout,长挂等待 runtime 应答)。
|
|
31
|
+
3. **watch 应答**:子 session 完成/死亡后 runtime 经 watch 通道回一条应答,reason 词表:
|
|
32
|
+
`completed` / `failed` / `stopped`(结果新闻)、`exited` / `deleted`(死亡新闻)、
|
|
33
|
+
`cancelled` / `orphaned`(静默注销,不产出通知)。应答经 50ms trailing debounce 攒批:
|
|
34
|
+
分拣键 (sessionId, reason),批身份 settleSeq/deathSeq——同一批合并为一条通知。
|
|
35
|
+
4. **record + 送达**:攒批结果经 B-ledger record 落账(见下节),送达为单通道
|
|
36
|
+
`pi.sendMessage({triggerTurn: true})`——只在主 session 空闲时刻投递(agent_settled
|
|
37
|
+
边沿复查 isIdle + 120s 看门狗兜底),送达回执按 notifyId 匹配销账(ack entry)。
|
|
38
|
+
|
|
39
|
+
通知文案形态:`Managed session "<label>" (<sessionId>) finished with status "<status>"
|
|
40
|
+
(exit code: N). (fulfills N request(s))`;stderr 非空时附 Stderr tail(400 字截尾);
|
|
41
|
+
runtime 查得到时附 `Full transcript: <sessionFilePath>` 指针行。
|
|
42
|
+
|
|
43
|
+
**合并与数量语义**:
|
|
44
|
+
|
|
45
|
+
- 多条 queued 消息在同一轮消费合并为一次通知(fulfills N = 本批兑现笔数);
|
|
46
|
+
- `create_managed_session` 还返回 `lifetimeNotifyId`:session 终局死亡(exited/deleted)
|
|
47
|
+
恒产出一次死亡通知,与结果通知相互独立;
|
|
48
|
+
- 每 notifyId 至多一条通知(record 幂等去重),重放不重复发声。
|
|
49
|
+
|
|
50
|
+
## B-ledger 账本存储
|
|
51
|
+
|
|
52
|
+
通知落账与恢复不依赖独立磁盘文件,复用 pi session 文件本身:
|
|
53
|
+
|
|
54
|
+
- **record = session 文件自定义 entry**:通知落账走 `pi.appendEntry` 写入 session 文件
|
|
55
|
+
(plain custom entry,不进 LLM 上下文),先落盘后投递——进程崩溃后债权不丢。
|
|
56
|
+
- **session_start 重装恢复**:每次 session_start 无条件 bind ledger host +
|
|
57
|
+
recoverFromSession——吸收 session 文件全部既有账本/回执条目重建内存态(重启重放),
|
|
58
|
+
按 notifyId 幂等去重:未销账债权重新投递,已销账静默。
|
|
59
|
+
- **session_compact 补写**:compaction 清掉账本/回执 entry 时按内存态补写
|
|
60
|
+
(compactionCheck),账本不因压缩丢账。
|
|
61
|
+
- ledger 实体在 `@zhushanwen/subagent-core`(Symbol.for 进程级单例槽),与
|
|
62
|
+
subagent-workflow 的通知账本共享同一实体——两个扩展共存时按 bind 收敛语义合并为
|
|
63
|
+
单实例,互不重复投递。
|
|
64
|
+
|
|
65
|
+
## 排查入口
|
|
66
|
+
|
|
67
|
+
| 症状 | 排查路径 |
|
|
68
|
+
|------|---------|
|
|
69
|
+
| 未收到完成通知 | ① 确认 taiji 桌面环境(standalone pi CLI 无 handler,工具本身就会超时);② 查扩展日志 `<agentDir>/logs/session-manager-YYYY-MM-DD.log`(agentDir = pi `getAgentDir()`,默认 `~/.pi/agent`,taiji 隔离环境 `~/.taiji/agent`)搜 `[session-manager]`——`notify ledger not bound`(session_start 未跑/装配失败,通知丢弃,下次 session_start 重试装配)、`watch select rejected`(watch 通道被拒)、`watch respond unusable/malformed`(runtime 应答不可解析,协议漂移信号);③ 日志干净则确认子 session 是否真完成(get_session_status) |
|
|
70
|
+
| pending 列表残留 type 'session' 条目 | register 由 send/create 带 prompt 的 arm 写入,正常由 watch 应答逐条 unregister。残留 = watch 应答未达(pi 进程被杀 / runtime 重启 / 通道折叠 cancelled);这些活跃 register 由下次 session_start 收口腿逐条重开 watch(入参 = entry id 即 notifyId),应答到达即静默注销,无需手工处理 |
|
|
71
|
+
| 同一完成重复通知 | 不应发生——record 按 notifyId 幂等、死亡槽 (sessionId, deathSeq) 同键只发首条。若复现即协议漂移,查扩展日志 `record failed` / `malformed` 留痕归因 |
|
|
72
|
+
|
|
73
|
+
## 工具速查
|
|
74
|
+
|
|
75
|
+
| 工具 | 说明 |
|
|
76
|
+
|------|------|
|
|
77
|
+
| `create_managed_session` | 创建子 session(cwd 必填;可选 label;可选 prompt 原子 create+send) |
|
|
78
|
+
| `send_to_session` | 向子 session 发 prompt(忙时排队,`queued: true` 立即返回) |
|
|
79
|
+
| `read_session_history` | 读子 session 对话历史(可选 tailTurns 截尾部 N 个 turn) |
|
|
80
|
+
| `list_my_sessions` | 列本 agent 管理的 session(顺带回填 label 缓存,修复重启后的文案 label 降级) |
|
|
81
|
+
| `get_session_status` | 查子 session 状态与模型信息 |
|
|
82
|
+
| `abort_session` | 中止运行中的子 session(终态 stopped) |
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// extension-harness.ts — session-manager 测试共享的 extension 装配 harness
|
|
2
|
+
//(tool-error-handling / tool-non-json-response / watch-orchestration /
|
|
3
|
+
// session-start-recovery 四份测试的 pi/ctx 桩逐字重复提取)。
|
|
4
|
+
// 提取的只是装配样板——select 通道行为(deferred watch / action 分发)与全部
|
|
5
|
+
// 断言留在各自测试文件。
|
|
6
|
+
//
|
|
7
|
+
// 初始化顺序约束:本文件运行时 import 被测 ../../index.ts(其再 import
|
|
8
|
+
// pi-extension-logger)——测试文件若 mock 该 logger 模块,须先 import
|
|
9
|
+
// ./logger-mock.ts 再 import 本文件(见 logger-mock.ts 文件头)。
|
|
10
|
+
|
|
11
|
+
import { expect, vi, type Mock } from "vitest";
|
|
12
|
+
|
|
13
|
+
import registerExtension from "../../index.ts";
|
|
14
|
+
|
|
15
|
+
/** registerTool 捕获的注册工具形态(execute 以六工具用例的固定五参形态调用) */
|
|
16
|
+
export interface RegisteredTool { // oe-exempt:20260930:test:测试 harness 契约——多测试文件的断言面引用其字段类型
|
|
17
|
+
name: string;
|
|
18
|
+
execute: (...args: unknown[]) => Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EmittedEvent { // oe-exempt:20260930:test:事件断言形状契约——多测试文件消费
|
|
22
|
+
event: string;
|
|
23
|
+
data: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ExtensionHarness {
|
|
27
|
+
registered: RegisteredTool[];
|
|
28
|
+
emitted: EmittedEvent[];
|
|
29
|
+
/** pi.on 注册的 handler(session_start / session_compact 收口腿触发面) */
|
|
30
|
+
handlers: Record<string, (event: unknown, ctx: unknown) => unknown>;
|
|
31
|
+
/** rpc 模式 ctx 桩(sessionManager.getEntries 读 mountExtension 的 entries 入参) */
|
|
32
|
+
ctx: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToolHarness extends ExtensionHarness { // oe-exempt:20260930:test:createToolHarness 返回契约,多测试文件消费其字段
|
|
36
|
+
selectMock: Mock;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 挂载被测 extension:registerTool 捕获工具、on 记录 handler、events.emit 收集事件,
|
|
41
|
+
* ctx 为 rpc 模式 + select 桩 + sessionManager(getEntries → entries)。
|
|
42
|
+
* select 通道实现由用例域提供:tool 级一次性应答(createToolHarness)或
|
|
43
|
+
* watch 级 deferred(各 watch 测试文件自建后传入)。
|
|
44
|
+
*/
|
|
45
|
+
export function mountExtension(select: Mock, entries: unknown[] = []): ExtensionHarness {
|
|
46
|
+
const registered: RegisteredTool[] = [];
|
|
47
|
+
const emitted: EmittedEvent[] = [];
|
|
48
|
+
const handlers: ExtensionHarness["handlers"] = {};
|
|
49
|
+
registerExtension({
|
|
50
|
+
registerTool: (tool: RegisteredTool) => registered.push(tool),
|
|
51
|
+
on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
|
52
|
+
handlers[event] = handler;
|
|
53
|
+
},
|
|
54
|
+
events: {
|
|
55
|
+
emit: (event: string, data: Record<string, unknown>) => {
|
|
56
|
+
emitted.push({ event, data });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
getAllTools: vi.fn(() => []),
|
|
60
|
+
setActiveTools: vi.fn(),
|
|
61
|
+
} as never);
|
|
62
|
+
const ctx = {
|
|
63
|
+
mode: "rpc" as const,
|
|
64
|
+
hasUI: true,
|
|
65
|
+
ui: { select },
|
|
66
|
+
sessionManager: { getEntries: () => entries, getSessionId: () => "parent-1" },
|
|
67
|
+
};
|
|
68
|
+
return { registered, emitted, handlers, ctx };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** tool 级 harness:select 桩由实现函数构成(一次性应答形态) */
|
|
72
|
+
export function createToolHarness(selectImpl: (...args: unknown[]) => Promise<unknown>): ToolHarness {
|
|
73
|
+
const selectMock = vi.fn(selectImpl);
|
|
74
|
+
return { ...mountExtension(selectMock), selectMock };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 一次 watch 开表调用(deferred——由测试按调用序应答) */
|
|
78
|
+
export interface WatchInvocation { // oe-exempt:20260930:test:watch 调用记录形状——断言面契约
|
|
79
|
+
notifyId: string;
|
|
80
|
+
resolve: (value: unknown) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 按开表顺序 resolve 第 n 次 watch(value = select 回包原样值) */
|
|
84
|
+
export function makeRespondWatch(watches: WatchInvocation[]): (index: number, value: unknown) => void {
|
|
85
|
+
return (index, value) => {
|
|
86
|
+
const inv = watches[index];
|
|
87
|
+
if (!inv) throw new Error(`no watch invocation at index ${index}`);
|
|
88
|
+
inv.resolve(value);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 纯微任务排干(不推进假时钟——攒批窗计时不受污染) */
|
|
93
|
+
export async function flushMicrotasks(): Promise<void> {
|
|
94
|
+
for (let i = 0; i < 12; i++) await Promise.resolve();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 工具文本结果形态(六工具 execute 成功路径的 content 投影) */
|
|
98
|
+
export interface ToolTextResult { // oe-exempt:20260930:test:runToolForText 返回契约——断言面消费
|
|
99
|
+
content: Array<{ type: string; text: string }>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findTool(h: Pick<ExtensionHarness, "registered">, name: string): RegisteredTool {
|
|
103
|
+
const t = h.registered.find((x) => x.name === name);
|
|
104
|
+
if (!t) throw new Error(`tool not registered: ${name}`);
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 跑一次工具(callId/params 形态与六工具用例一致:双 undefined + harness ctx) */
|
|
109
|
+
export async function runToolCall(
|
|
110
|
+
h: Pick<ExtensionHarness, "registered" | "ctx">,
|
|
111
|
+
name: string,
|
|
112
|
+
params: Record<string, unknown>,
|
|
113
|
+
callId = "call-1",
|
|
114
|
+
): Promise<unknown> {
|
|
115
|
+
return findTool(h, name).execute(callId, params, undefined, undefined, h.ctx);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 跑一次工具并取文本结果(成功路径断言用) */
|
|
119
|
+
export async function runToolForText(
|
|
120
|
+
h: Pick<ExtensionHarness, "registered" | "ctx">,
|
|
121
|
+
name: string,
|
|
122
|
+
params: Record<string, unknown>,
|
|
123
|
+
callId = "call-1",
|
|
124
|
+
): Promise<ToolTextResult> {
|
|
125
|
+
return (await runToolCall(h, name, params, callId)) as ToolTextResult;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 工具执行 reject 断言(W4 throw 范式用例形态) */
|
|
129
|
+
export async function expectToolRejects(
|
|
130
|
+
h: Pick<ExtensionHarness, "registered" | "ctx">,
|
|
131
|
+
name: string,
|
|
132
|
+
params: Record<string, unknown>,
|
|
133
|
+
pattern: RegExp,
|
|
134
|
+
callId = "call-1",
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await expect(runToolCall(h, name, params, callId)).rejects.toThrow(pattern);
|
|
137
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// logger-mock.ts — session-manager 测试共享的 pi-extension-logger 模块 mock
|
|
2
|
+
//(notify-ledger-discipline / tool-non-json-response / watch-orchestration 三份
|
|
3
|
+
// 逐字重复的 vi.hoisted + vi.mock 样板提取)。
|
|
4
|
+
//
|
|
5
|
+
// 使用方式(测试文件内):
|
|
6
|
+
// import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
|
|
7
|
+
// vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
|
|
8
|
+
// const loggerMock = getLoggerMock();
|
|
9
|
+
//
|
|
10
|
+
// 初始化顺序约束(同 subagent-workflow injector-test-mocks 先例):本文件只有
|
|
11
|
+
// vitest 一个运行时依赖、不 import 被测模块——vi.mock 工厂惰性执行时引用本文件的
|
|
12
|
+
// import 绑定,故测试文件里本 import 必须先于任何会加载 pi-extension-logger 的
|
|
13
|
+
// import(../index.ts / ../notify-ledger.ts / extension-harness helper)。
|
|
14
|
+
// 模块级单例按测试文件隔离(vitest 默认 isolate),getLoggerMock() 与被 mock 模块
|
|
15
|
+
// 的 getLogger() 返回同一引用——断言面与桩面同源。
|
|
16
|
+
|
|
17
|
+
import { vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
/** 与被 mock 模块 getLogger() 同源的 spy 集(断言面经 getLoggerMock() 取用) */
|
|
20
|
+
const loggerMock = {
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
debug: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type LoggerMock = typeof loggerMock;
|
|
27
|
+
|
|
28
|
+
/** 断言面:取本测试文件的 logger spy(与 mock 模块内 getLogger() 返回值同引用) */
|
|
29
|
+
export function getLoggerMock(): LoggerMock {
|
|
30
|
+
return loggerMock;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** pi-extension-logger 模块 mock 工厂(vi.mock 内调用;须写 () => createLoggerModuleMock() 惰性形式) */
|
|
34
|
+
export function createLoggerModuleMock() {
|
|
35
|
+
return {
|
|
36
|
+
getLogger: () => loggerMock,
|
|
37
|
+
setPiHandle: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// notify-content.test.ts — buildManagedNotifyContent 纯函数(U6_UNIT 随迁 + D9 新形态)。
|
|
2
|
+
// 原 runtime completion-backflow.test.ts 的 U6_UNIT 组随 buildBackflowContent 迁入本包。
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
|
|
8
|
+
buildManagedNotifyContent,
|
|
9
|
+
} from "../notify-content.ts";
|
|
10
|
+
|
|
11
|
+
describe("MANAGED_SESSION_NOTIFY_CUSTOM_TYPE SSOT", () => {
|
|
12
|
+
it("常量值锁定为 managed-session-notify(与 shared COMPLETE_NOTIFY_CUSTOM_TYPES / core extractNotifyRecords 三处同值,防字面量漂移)", () => {
|
|
13
|
+
expect(MANAGED_SESSION_NOTIFY_CUSTOM_TYPE).toBe("managed-session-notify");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("buildManagedNotifyContent(D9 文案形状)", () => {
|
|
18
|
+
it("settle 基本形态:label/status + 指针行缺席时省略", () => {
|
|
19
|
+
expect(
|
|
20
|
+
buildManagedNotifyContent({ label: "auth-refactor", sessionId: "a1b2", status: "completed" }),
|
|
21
|
+
).toBe('Managed session "auth-refactor" (a1b2) finished with status "completed".');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("fulfills N 行内追加:单数 request / 复数 requests(裁决4 合并笔数)", () => {
|
|
25
|
+
expect(
|
|
26
|
+
buildManagedNotifyContent({
|
|
27
|
+
label: "L",
|
|
28
|
+
sessionId: "s1",
|
|
29
|
+
status: "completed",
|
|
30
|
+
fulfills: 1,
|
|
31
|
+
}),
|
|
32
|
+
).toContain(". (fulfills 1 request)");
|
|
33
|
+
expect(
|
|
34
|
+
buildManagedNotifyContent({
|
|
35
|
+
label: "L",
|
|
36
|
+
sessionId: "s1",
|
|
37
|
+
status: "completed",
|
|
38
|
+
fulfills: 2,
|
|
39
|
+
}),
|
|
40
|
+
).toContain(". (fulfills 2 requests)");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("fulfills 0 / 缺席 → 不出行(纯死亡通知无债权)", () => {
|
|
44
|
+
const zero = buildManagedNotifyContent({
|
|
45
|
+
label: "L",
|
|
46
|
+
sessionId: "s1",
|
|
47
|
+
status: "exited",
|
|
48
|
+
fulfills: 0,
|
|
49
|
+
});
|
|
50
|
+
const absent = buildManagedNotifyContent({ label: "L", sessionId: "s1", status: "exited" });
|
|
51
|
+
expect(zero).not.toContain("fulfills");
|
|
52
|
+
expect(absent).not.toContain("fulfills");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("exit code 注入 status 段内(death 文案,exit 腿诊断通路复刻)", () => {
|
|
56
|
+
const content = buildManagedNotifyContent({
|
|
57
|
+
label: "L",
|
|
58
|
+
sessionId: "s1",
|
|
59
|
+
status: "exited",
|
|
60
|
+
exitCode: 1,
|
|
61
|
+
fulfills: 1,
|
|
62
|
+
});
|
|
63
|
+
expect(content).toBe(
|
|
64
|
+
'Managed session "L" (s1) finished with status "exited" (exit code: 1). (fulfills 1 request)',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("exitCode null → 字面 'null'(迁移自 completion-backflow 语义)", () => {
|
|
69
|
+
expect(
|
|
70
|
+
buildManagedNotifyContent({ label: "L", sessionId: "s1", status: "exited", exitCode: null }),
|
|
71
|
+
).toContain('(exit code: null)');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("stderrTail 非空 → Stderr 行;>400 字取尾部截断;全空白不输出", () => {
|
|
75
|
+
const long = `${"x".repeat(500)}TAIL`;
|
|
76
|
+
const tail = ` ${long} `;
|
|
77
|
+
const content = buildManagedNotifyContent({
|
|
78
|
+
label: "L",
|
|
79
|
+
sessionId: "s1",
|
|
80
|
+
status: "exited",
|
|
81
|
+
stderrTail: tail,
|
|
82
|
+
});
|
|
83
|
+
// 截尾语义与迁移源一致:对原始 tail 切尾 400 字后 trim
|
|
84
|
+
expect(content).toContain(`\nStderr: ${tail.slice(-400).trim()}`);
|
|
85
|
+
expect(content).not.toContain("x".repeat(450));
|
|
86
|
+
|
|
87
|
+
const blank = buildManagedNotifyContent({
|
|
88
|
+
label: "L",
|
|
89
|
+
sessionId: "s1",
|
|
90
|
+
status: "exited",
|
|
91
|
+
stderrTail: " \n ",
|
|
92
|
+
});
|
|
93
|
+
expect(blank).not.toContain("Stderr");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("sessionFilePath 提供时补 Full transcript 行(watch respond payload 已携该字段;未携 → 整行省略语义不变)", () => {
|
|
97
|
+
const content = buildManagedNotifyContent({
|
|
98
|
+
label: "L",
|
|
99
|
+
sessionId: "s1",
|
|
100
|
+
status: "completed",
|
|
101
|
+
sessionFilePath: "/data/agent/sessions/s1.jsonl",
|
|
102
|
+
});
|
|
103
|
+
expect(content).toContain("\nFull transcript: /data/agent/sessions/s1.jsonl");
|
|
104
|
+
expect(
|
|
105
|
+
buildManagedNotifyContent({ label: "L", sessionId: "s1", status: "completed" }),
|
|
106
|
+
).not.toContain("Full transcript");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// notify-ledger-discipline.test.ts — ledger 装配纪律(notify-once D3 ①-⑤ / U4)。
|
|
2
|
+
// 包名 import @zhushanwen/subagent-core(D-10 领地补录后的 canonical 形态,相对路径 hack 已删)。
|
|
3
|
+
// 覆盖:
|
|
4
|
+
// ① ensureLedgerBound 无条件重 bind(唯一装配方形态,与 subagent-workflow
|
|
5
|
+
// bindLedgerHostAndRecover 同款——槽上不残留捕获旧 ctx 的 host);
|
|
6
|
+
// ② 槽空 → bind + recoverFromSession 成对(recover 吸收 session 文件既有条目——用未销账
|
|
7
|
+
// ledger entry 种子断言 pendingCount,证明 recover 真跑过);
|
|
8
|
+
// ③ 双 bind 收敛——「先 bind 方在后 bind 之后 record 仍返回 true」(动态查槽恒取当前实例;
|
|
9
|
+
// 缓存前实例引用则 record 恒 false 零日志——D3⑤ 事故形态反证);
|
|
10
|
+
// ④ compactionCheck 直通当前实例、抛错不外抛(接入点降级,STANDARDS §11.1);
|
|
11
|
+
// ⑤ 消费点 record 动态查槽 + deliveryCustomType = managed-session-notify;
|
|
12
|
+
// record 先 appendEntry 落盘后内存更新(时序钉:append 快照 = 该次 record 前计数);
|
|
13
|
+
// sendDelivery stale ctx 守卫:stale 抛错静默降级 + warn 归因(条目标 sentAt 不挂回),
|
|
14
|
+
// 非 stale 错误原样上抛走 settleRejected 留账重试;
|
|
15
|
+
// 槽空降级(ensure 未跑/失败时 record → warn + false,用例登记)。
|
|
16
|
+
|
|
17
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
// logger mock helper 必须先于任何会加载 pi-extension-logger 的 import
|
|
19
|
+
//(subagent-core barrel / ../notify-ledger.ts),见 logger-mock.ts 文件头
|
|
20
|
+
import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
|
|
21
|
+
import {
|
|
22
|
+
bindNotifyLedgerHost,
|
|
23
|
+
getBoundNotifyLedger,
|
|
24
|
+
type NotifyLedgerHost,
|
|
25
|
+
} from "@zhushanwen/subagent-core";
|
|
26
|
+
import { STALE_CTX_MARKER } from "@zhushanwen/pi-ext-guards";
|
|
27
|
+
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
|
|
29
|
+
|
|
30
|
+
const loggerMock = getLoggerMock();
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
ensureLedgerBound,
|
|
34
|
+
recordManagedNotify,
|
|
35
|
+
runLedgerCompactionCheck,
|
|
36
|
+
} from "../notify-ledger.ts";
|
|
37
|
+
import type { ManagedNotifyDetails } from "../notify-content.ts";
|
|
38
|
+
|
|
39
|
+
/** recordManagedNotify 的最小合法 details(ManagedNotifyDetails 收紧后的测试桩形状) */
|
|
40
|
+
function makeDetails(notifyId: string): ManagedNotifyDetails {
|
|
41
|
+
return { notifyId, sessionId: "s-test", reason: "completed", fulfills: 1, label: "test" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface HostProbe {
|
|
45
|
+
host: NotifyLedgerHost;
|
|
46
|
+
appendLedgerEntries: Array<{ customType: string; data: Record<string, unknown> }>;
|
|
47
|
+
/** 每次 append 发生时账本 pendingCount 快照——时序钉:恒等于该次 record 前的计数 */
|
|
48
|
+
pendingCountsAtAppend: number[];
|
|
49
|
+
attach(pendingCount: () => number): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeHost(): HostProbe {
|
|
53
|
+
const appendLedgerEntries: HostProbe["appendLedgerEntries"] = [];
|
|
54
|
+
const pendingCountsAtAppend: number[] = [];
|
|
55
|
+
let pendingCount: (() => number) | undefined;
|
|
56
|
+
const host: NotifyLedgerHost = {
|
|
57
|
+
appendLedgerEntry(customType, data) {
|
|
58
|
+
// 时序钉(D3 ③):record 先 appendEntry 落盘、后内存更新——此刻本条尚未入账
|
|
59
|
+
if (pendingCount) pendingCountsAtAppend.push(pendingCount());
|
|
60
|
+
appendLedgerEntries.push({ customType, data: data as Record<string, unknown> });
|
|
61
|
+
},
|
|
62
|
+
readSessionEntries: () => [],
|
|
63
|
+
isIdle: () => false,
|
|
64
|
+
onAgentSettled: () => {},
|
|
65
|
+
sendDelivery: () => {},
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
host,
|
|
69
|
+
appendLedgerEntries,
|
|
70
|
+
pendingCountsAtAppend,
|
|
71
|
+
attach: (fn) => {
|
|
72
|
+
pendingCount = fn;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** ensureLedgerBound 的最小 pi/ctx 桩(appendEntry/on/sendMessage + getEntries/isIdle) */
|
|
78
|
+
function makePiCtx(
|
|
79
|
+
entries: unknown[],
|
|
80
|
+
opts?: { isIdle?: boolean; sendMessage?: () => void },
|
|
81
|
+
) {
|
|
82
|
+
const pi = {
|
|
83
|
+
appendEntry: vi.fn((customType: string, data: unknown) => {
|
|
84
|
+
entries.push({ type: "custom", customType, data });
|
|
85
|
+
}),
|
|
86
|
+
on: vi.fn(),
|
|
87
|
+
sendMessage: vi.fn(opts?.sendMessage ?? (() => {})),
|
|
88
|
+
};
|
|
89
|
+
const ctx = {
|
|
90
|
+
sessionManager: { getEntries: vi.fn(() => entries) },
|
|
91
|
+
isIdle: () => opts?.isIdle ?? false,
|
|
92
|
+
};
|
|
93
|
+
return { pi, ctx };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
// dispose 摘除模块级绑定(bind 路径实例 current===api → setBoundLedger(undefined))
|
|
98
|
+
getBoundNotifyLedger()?.dispose();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("ledger 装配纪律(D3 ①-⑤,canonical import)", () => {
|
|
102
|
+
it("槽空 → recordManagedNotify 降级 false(ensure 未跑/失败时的登记降级,warn 留痕)", () => {
|
|
103
|
+
expect(getBoundNotifyLedger()).toBeUndefined();
|
|
104
|
+
expect(recordManagedNotify("sm-x", "content", makeDetails("sm-x"))).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("⑤ bind 后 record 经动态查槽入账、deliveryCustomType = managed-session-notify;时序钉:append 先于内存更新", () => {
|
|
108
|
+
const probe = makeHost();
|
|
109
|
+
const ledger = bindNotifyLedgerHost(probe.host);
|
|
110
|
+
probe.attach(() => ledger.pendingCount());
|
|
111
|
+
expect(getBoundNotifyLedger()).toBe(ledger);
|
|
112
|
+
expect(recordManagedNotify("sm-a", "hello", makeDetails("sm-a"))).toBe(true);
|
|
113
|
+
expect(probe.appendLedgerEntries).toHaveLength(1);
|
|
114
|
+
expect(probe.appendLedgerEntries[0].customType).toBe("subagent-bg-notify-ledger");
|
|
115
|
+
expect(probe.appendLedgerEntries[0].data.deliveryCustomType).toBe("managed-session-notify");
|
|
116
|
+
expect(probe.appendLedgerEntries[0].data.content).toBe("hello");
|
|
117
|
+
expect(probe.pendingCountsAtAppend).toEqual([0]);
|
|
118
|
+
expect(ledger.pendingCount()).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("③ 先 bind 方在后 bind 之后 record 仍返回 true(后 bind 者收敛单实例;缓存前实例引用则恒 false——D3⑤ 反证)", () => {
|
|
122
|
+
const probeA = makeHost();
|
|
123
|
+
const ledgerA = bindNotifyLedgerHost(probeA.host);
|
|
124
|
+
probeA.attach(() => ledgerA.pendingCount());
|
|
125
|
+
expect(recordManagedNotify("sm-1", "n1", makeDetails("sm-1"))).toBe(true);
|
|
126
|
+
expect(probeA.appendLedgerEntries).toHaveLength(1);
|
|
127
|
+
|
|
128
|
+
// 后 bind 方(模拟 subagent-workflow session_start 的 re-bind + recover 成对——
|
|
129
|
+
// bindNotifyLedgerHost 内部先 dispose 旧实例再 set,收敛为单实例全量态)
|
|
130
|
+
const probeB = makeHost();
|
|
131
|
+
const ledgerB = bindNotifyLedgerHost(probeB.host);
|
|
132
|
+
probeB.attach(() => ledgerB.pendingCount());
|
|
133
|
+
|
|
134
|
+
// 消费点动态查槽恒取当前实例:先 bind 方的后续 record 落后 bind 实例且返回 true
|
|
135
|
+
expect(getBoundNotifyLedger()).toBe(ledgerB);
|
|
136
|
+
expect(recordManagedNotify("sm-2", "n2", makeDetails("sm-2"))).toBe(true);
|
|
137
|
+
expect(probeB.appendLedgerEntries.map((e) => e.data.notifyId)).toEqual(["sm-2"]);
|
|
138
|
+
expect(probeA.appendLedgerEntries).toHaveLength(1); // 前实例不再收账
|
|
139
|
+
// 时序钉跨 bind 仍成立:sm-2 时 B 账面 0(append 快照 = 该次 record 前计数)
|
|
140
|
+
expect(probeB.pendingCountsAtAppend).toEqual([0]);
|
|
141
|
+
|
|
142
|
+
// 反证:若消费点缓存了前实例引用,record 恒 false 且零日志(对半概率丢全部通知的事故形态)
|
|
143
|
+
expect(ledgerA.record("sm-3", "n3", {})).toBe(false);
|
|
144
|
+
// 而动态查槽(本包消费面)依旧 true
|
|
145
|
+
expect(recordManagedNotify("sm-4", "n4", makeDetails("sm-4"))).toBe(true);
|
|
146
|
+
expect(probeB.appendLedgerEntries).toHaveLength(2);
|
|
147
|
+
expect(probeB.pendingCountsAtAppend).toEqual([0, 1]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("② 槽空 + ensureLedgerBound → bind + recoverFromSession 成对(既有未销账 ledger entry 被 recover 吸收)", () => {
|
|
151
|
+
const entries: unknown[] = [
|
|
152
|
+
{
|
|
153
|
+
type: "custom",
|
|
154
|
+
customType: "subagent-bg-notify-ledger",
|
|
155
|
+
data: {
|
|
156
|
+
v: 1,
|
|
157
|
+
notifyId: "sm-recovered",
|
|
158
|
+
content: "prior notification",
|
|
159
|
+
record: { notifyId: "sm-recovered" },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
const { pi, ctx } = makePiCtx(entries);
|
|
164
|
+
expect(getBoundNotifyLedger()).toBeUndefined();
|
|
165
|
+
|
|
166
|
+
ensureLedgerBound(pi as never, ctx as never);
|
|
167
|
+
const ledger = getBoundNotifyLedger();
|
|
168
|
+
expect(ledger).toBeDefined();
|
|
169
|
+
// recover 真跑过:session 文件的未销账条目被吸收进内存态
|
|
170
|
+
expect(ctx.sessionManager.getEntries).toHaveBeenCalled();
|
|
171
|
+
expect(ledger!.pendingCount()).toBe(1);
|
|
172
|
+
// 装配后消费面就绪
|
|
173
|
+
expect(recordManagedNotify("sm-new", "n", makeDetails("sm-new"))).toBe(true);
|
|
174
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
175
|
+
"subagent-bg-notify-ledger",
|
|
176
|
+
expect.objectContaining({ notifyId: "sm-new", deliveryCustomType: "managed-session-notify" }),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("① 槽已有实例 + ensureLedgerBound → 无条件重 bind(后 bind 收敛接管 + recover 重跑)", () => {
|
|
181
|
+
const probe = makeHost();
|
|
182
|
+
const ledger = bindNotifyLedgerHost(probe.host);
|
|
183
|
+
probe.attach(() => ledger.pendingCount());
|
|
184
|
+
|
|
185
|
+
const { pi, ctx } = makePiCtx([]);
|
|
186
|
+
ensureLedgerBound(pi as never, ctx as never);
|
|
187
|
+
// 后 bind 收敛:旧实例被 dispose,槽切到新实例(槽上不残留捕获旧 ctx 的 host)
|
|
188
|
+
const rebound = getBoundNotifyLedger();
|
|
189
|
+
expect(rebound).toBeDefined();
|
|
190
|
+
expect(rebound).not.toBe(ledger);
|
|
191
|
+
// recover 重跑:新 host 的 readSessionEntries 被消费
|
|
192
|
+
expect(ctx.sessionManager.getEntries).toHaveBeenCalled();
|
|
193
|
+
// 消费面动态查槽:record 落新实例(经新 host 的 appendLedgerEntry = pi.appendEntry)
|
|
194
|
+
expect(recordManagedNotify("sm-1", "n", makeDetails("sm-1"))).toBe(true);
|
|
195
|
+
expect(pi.appendEntry).toHaveBeenCalled();
|
|
196
|
+
|
|
197
|
+
// 重复调用同款:每次 session_start 都重装配(不查槽短路)
|
|
198
|
+
ensureLedgerBound(pi as never, ctx as never);
|
|
199
|
+
expect(getBoundNotifyLedger()).not.toBe(rebound);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("sendDelivery stale ctx 守卫:stale 抛错静默降级 + warn 归因(条目标 sentAt,不外抛不挂回)", () => {
|
|
203
|
+
const { pi, ctx } = makePiCtx([], {
|
|
204
|
+
isIdle: true,
|
|
205
|
+
sendMessage: () => {
|
|
206
|
+
throw new Error(`ctx ${STALE_CTX_MARKER}`);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
ensureLedgerBound(pi as never, ctx as never);
|
|
210
|
+
const ledger = getBoundNotifyLedger();
|
|
211
|
+
expect(ledger).toBeDefined();
|
|
212
|
+
expect(recordManagedNotify("sm-stale", "n", makeDetails("sm-stale"))).toBe(true);
|
|
213
|
+
expect(() => ledger!.attemptDeliver()).not.toThrow();
|
|
214
|
+
// stale 降级语义:本条不投递且按已受理标 sentAt(attemptDeliver 摘除,不挂回反复撞)
|
|
215
|
+
expect(ledger!.pendingCount()).toBe(0);
|
|
216
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
217
|
+
"[session-manager] notify delivery skipped (stale ctx)",
|
|
218
|
+
expect.objectContaining({ error: expect.stringContaining("stale") }),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("sendDelivery 非 stale 错误原样上抛 → settleRejected 留账重试(pending 不摘、无 stale 归因)", () => {
|
|
223
|
+
// 清上一用例的 warn 记录——断言只认本用例内的调用
|
|
224
|
+
loggerMock.warn.mockClear();
|
|
225
|
+
const { pi, ctx } = makePiCtx([], {
|
|
226
|
+
isIdle: true,
|
|
227
|
+
sendMessage: () => {
|
|
228
|
+
throw new Error("boom");
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
ensureLedgerBound(pi as never, ctx as never);
|
|
232
|
+
const ledger = getBoundNotifyLedger();
|
|
233
|
+
expect(ledger).toBeDefined();
|
|
234
|
+
expect(recordManagedNotify("sm-err", "n", makeDetails("sm-err"))).toBe(true);
|
|
235
|
+
expect(() => ledger!.attemptDeliver()).not.toThrow();
|
|
236
|
+
// 非 stale 走 attemptDeliver 既有 catch:settleRejected 留账等下一边沿重试
|
|
237
|
+
expect(ledger!.pendingCount()).toBe(1);
|
|
238
|
+
expect(loggerMock.warn).not.toHaveBeenCalledWith(
|
|
239
|
+
"[session-manager] notify delivery skipped (stale ctx)",
|
|
240
|
+
expect.anything(),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("④ compactionCheck 经动态查槽直通当前实例", () => {
|
|
245
|
+
const probe = makeHost();
|
|
246
|
+
const ledger = bindNotifyLedgerHost(probe.host);
|
|
247
|
+
probe.attach(() => ledger.pendingCount());
|
|
248
|
+
const spy = vi.spyOn(getBoundNotifyLedger()!, "compactionCheck").mockReturnValue(0);
|
|
249
|
+
runLedgerCompactionCheck();
|
|
250
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("④ compactionCheck 抛错 → warn 吞掉不外抛(辅助面接入点降级,STANDARDS §11.1)", () => {
|
|
254
|
+
bindNotifyLedgerHost(makeHost().host);
|
|
255
|
+
vi.spyOn(getBoundNotifyLedger()!, "compactionCheck").mockImplementation(() => {
|
|
256
|
+
throw new Error("boom");
|
|
257
|
+
});
|
|
258
|
+
expect(() => runLedgerCompactionCheck()).not.toThrow();
|
|
259
|
+
});
|
|
260
|
+
});
|