@zhushanwen/pi-session-manager 0.4.0 → 0.6.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/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # @zhushanwen/pi-session-manager
2
2
 
3
- agent-managed session pi extension:把 session 的创建/发送/读取/列表/状态/中止交给 agent 自主管理——agent 通过 6 个工具以子 session 形态并行工作,父 session 统一编排。
3
+ agent-managed session pi extension:把 session 的创建/发送/读取/列表/状态/中止/归档/删除交给 agent 自主管理——agent 通过 8 个工具以子 session 形态并行工作,父 session 统一编排。
4
4
 
5
5
  ## 通道契约
6
6
 
7
- - 工具调用经 `ctx.ui.select(SESSION_MANAGER_MARKER, [JSON], {timeout})` 发出,超时按 action 分档(create/history 60s、其余 30s,SSOT 在 `src/index.ts` 的 `SELECT_TIMEOUT_MS`);marker 为 `\x00TAIJI_SESSION_MANAGER`(NUL 前缀防与普通 select title 冲突,SSOT 在 `@zhushanwen/extension-protocol`)
7
+ - 工具调用经 `ctx.ui.select(SESSION_MANAGER_MARKER, [JSON])` 发出,全 action 不传 timeout(ADR-0122:无包内挂死兜底,handler 不回包时工具调用长挂);marker 为 `\x00TAIJI_SESSION_MANAGER`(NUL 前缀防与普通 select title 冲突,SSOT 在 `@zhushanwen/extension-protocol`)
8
8
  - 请求体为嵌套形状 `{ action, params }`(形状 SSOT 在 `@zhushanwen/extension-protocol`);**不要扁平化展开**——runtime event-adapter 按 `data.params` 提取,扁平化会导致 params 丢失
9
- - 应答方是 taiji runtime 的 `SessionManagerHandler`(select value 通道回写 JSON 字符串;取消/超时返回 null)
9
+ - 应答方是 taiji runtime 的 `SessionManagerHandler`(select value 通道回写 JSON 字符串;取消返回 null——本扩展不传 timeout、无超时态,pi select 层对取消/超时本就不可区分,见 protocol `callMarkerRpc`)
10
10
 
11
- ## 工具(6 个工具,每工具 1 个 action)
11
+ ## 工具(8 个工具,每工具 1 个 action)
12
12
 
13
13
  | 工具 | action | 说明 |
14
14
  |------|--------|------|
@@ -18,10 +18,12 @@ agent-managed session pi extension:把 session 的创建/发送/读取/列表/
18
18
  | `list_my_sessions` | list | 列出本 agent 管理的 session(无过滤参数,自动限定发起方 agent) |
19
19
  | `get_session_status` | status | 查询子 session 状态与模型信息 |
20
20
  | `abort_session` | abort | 中止运行中的子 session |
21
+ | `archive_session` | archive | 归档子 session(可恢复):会话退场进归档区(文件压缩搬移),用户可随时查看摘要并恢复;agent 可主动调用做任务收尾整理,生成中的会话拒绝归档(先 `abort_session` 或等完成) |
22
+ | `delete_session` | delete | 彻底删除子 session(不可恢复):进程 + 历史 + 衍生数据整树进废纸篓。USER-INITIATED ONLY:仅当用户消息明确表达不可恢复的删除意图时调用,agent 永不主动发起;模糊表达(「清理掉/收掉」等)一律归 `archive_session` |
21
23
 
22
24
  ## 运行要求
23
25
 
24
- 应答端(runtime handler)在 taiji 桌面应用内;独立 pi CLI 环境无 handler 应答,工具会等待至超时(create/history 60s、其余 30s)后返回 isError 错误结果(文案 `cancelled or timed out`——pi 实装对取消与超时不可区分,见 protocol `callMarkerRpc`)。
26
+ 应答端(runtime handler)在 taiji 桌面应用内;独立 pi CLI 环境无 handler 应答,工具调用长挂(hangs forever——不传 timeout,ADR-0122)。通道取消/断开折叠为 isError 错误结果(文案开头 `cancelled or channel error — check the taiji runtime connection first…`,含重试安全性与 duplicate-notifyId 例外,全文见 `src/index.ts` 的 executeTool)。
25
27
 
26
28
  ## 安装
27
29
 
@@ -31,7 +33,7 @@ pi install npm:@zhushanwen/pi-session-manager
31
33
 
32
34
  ## 工具激活(deferred)
33
35
 
34
- 上表 6 个工具均为 `exposure: "deferred"`——不占常驻工具声明,由 pi 内置 `tool_search` 工具检索命中后按需激活。taiji 宿主内开箱即用(宿主启动时已激活 `tool_search`);独立 pi 使用需在 `<agentDir>/settings.json`(默认 `~/.pi/agent/settings.json`)加 `"defaultTools": ["+tool_search"]`(`+` 前缀是增量修饰,保留 read/bash/edit/write 默认集)。注意独立 pi 下即便激活,工具功能仍依赖 taiji runtime handler(见上节「运行要求」)。
36
+ 上表 8 个工具均为 `exposure: "deferred"`——不占常驻工具声明,由 pi 内置 `tool_search` 工具检索命中后按需激活。taiji 宿主内开箱即用(宿主启动时已激活 `tool_search`);独立 pi 使用需在 `<agentDir>/settings.json`(默认 `~/.pi/agent/settings.json`)加 `"defaultTools": ["+tool_search"]`(`+` 前缀是增量修饰,保留 read/bash/edit/write 默认集)。注意独立 pi 下即便激活,工具功能仍依赖 taiji runtime handler(见上节「运行要求」)。
35
37
 
36
38
  ## 测试
37
39
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-manager",
3
- "version": "0.4.0",
4
- "description": "Pi extension for managing agent-managed sessions — create, send, read history, list, status, abort via ctx.ui.select channel.",
3
+ "version": "0.6.0",
4
+ "description": "Pi extension for agent-managed sessions — create, message, monitor, abort, archive, and delete child sessions via ctx.ui.select channel.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "pi": {
@@ -30,11 +30,10 @@
30
30
  "README.md"
31
31
  ],
32
32
  "dependencies": {
33
- "@zhushanwen/extension-protocol": "0.21.0",
33
+ "@zhushanwen/extension-protocol": "0.23.0",
34
+ "@zhushanwen/pi-delivery-port": "0.2.1",
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.5",
37
- "@zhushanwen/subagent-core": "1.5.3"
36
+ "@zhushanwen/pi-extension-logger": "0.6.2"
38
37
  },
39
38
  "devDependencies": {
40
39
  "@types/node": "^24.0.0",
@@ -1,22 +1,23 @@
1
1
  ---
2
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。"
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 死亡通知、archive_session/delete_session 成功后 watch 收 exited/deleted 的收尾语义、未收到通知与 pending 残留的排查入口。触发词:session-manager、managed session、create_managed_session、send_to_session、archive_session、delete_session、完成通知没收到、通知未送达、notifyId、pending 列表有 session 条目、投递日志、delivery-port、managed-session-notify、willNotify、session-manager-ext-config。"
4
4
  ---
5
5
 
6
6
  # session-manager 使用与排查指南
7
7
 
8
- > @zhushanwen/pi-session-manager:agent-managed session 扩展。父 session 通过 6 个工具
8
+ > @zhushanwen/pi-session-manager:agent-managed session 扩展。父 session 通过 8 个工具
9
9
  > (create_managed_session / send_to_session / read_session_history / list_my_sessions /
10
- > get_session_status / abort_session)以子 session 形态并行工作。本指南讲完成通知链、
11
- > 账本存储与排查入口;工具契约细节(marker 通道 / 超时分档)见包 README。
10
+ > get_session_status / abort_session / delete_session / archive_session)以子 session 形态
11
+ > 并行工作。本指南讲完成通知链、投递日志与排查入口;工具契约细节(marker 通道 /
12
+ > 无 timeout 长挂语义)见包 README。
12
13
 
13
14
  ## 重要前提
14
15
 
15
16
  - 工具应答端是 taiji runtime 的 SessionManagerHandler——**只在 taiji 桌面环境可用**;
16
- 独立 pi CLI 无 handler 应答,所有工具等待至超时后报 `cancelled or timed out`。
17
- 工具失败时先确认运行环境,不要怀疑参数。
17
+ 独立 pi CLI 无 handler 应答,所有工具长挂(hangs forever,不传 timeout);通道取消/断开
18
+ 报 `cancelled or channel error`。工具失败时先确认运行环境,不要怀疑参数。
18
19
  - 本扩展不读磁盘配置文件,无 config JSON——通知状态全部存储在 pi session 文件内
19
- (见 B-ledger 一节),没有「改配置文件」这条路径。
20
+ (见投递日志一节),没有「改配置文件」这条路径。
20
21
 
21
22
  ## 完成通知链(notifyId → watch → managed-session-notify)
22
23
 
@@ -29,12 +30,13 @@ description: "使用或排查 @zhushanwen/pi-session-manager(agent-managed ses
29
30
  type: 'session', name = label},由 pending-notifications 扩展消费落 pending 列表)+
30
31
  开 watch(fire-and-forget,不传 timeout,长挂等待 runtime 应答)。
31
32
  3. **watch 应答**:子 session 完成/死亡后 runtime 经 watch 通道回一条应答,reason 词表:
32
- `completed` / `failed` / `stopped`(结果新闻)、`exited` / `deleted`(死亡新闻)、
33
+ `completed` / `failed` / `stopped`(结果新闻)、`exited` / `deleted`(死亡新闻——
34
+ archive_session / delete_session 工具成功后的收尾即产出这两种应答)、
33
35
  `cancelled` / `orphaned`(静默注销,不产出通知)。应答经 50ms trailing debounce 攒批:
34
36
  分拣键 (sessionId, reason),批身份 settleSeq/deathSeq——同一批合并为一条通知。
35
- 4. **record + 送达**:攒批结果经 B-ledger record 落账(见下节),送达为单通道
36
- `pi.sendMessage({triggerTurn: true})`——只在主 session 空闲时刻投递(agent_settled
37
- 边沿复查 isIdle + 120s 看门狗兜底),送达回执按 notifyId 匹配销账(ack entry)。
37
+ 4. **入账投递**:攒批结果经 `recordManagedNotify` → delivery-port 的 `port.deliver`
38
+ 提交(见下节),注入直达 pi 唯一队列(steer 档 + `triggerTurn: true`,pi 忙时入队
39
+ 不拒、轮边界注入),送达回执按 notifyId 的 details-id 凭据匹配逐条处理完毕(ack entry)。
38
40
 
39
41
  通知文案形态:`Managed session "<label>" (<sessionId>) finished with status "<status>"
40
42
  (exit code: N). (fulfills N request(s))`;stderr 非空时附 Stderr tail(400 字截尾);
@@ -45,30 +47,32 @@ runtime 查得到时附 `Full transcript: <sessionFilePath>` 指针行。
45
47
  - 多条 queued 消息在同一轮消费合并为一次通知(fulfills N = 本批兑现笔数);
46
48
  - `create_managed_session` 还返回 `lifetimeNotifyId`:session 终局死亡(exited/deleted)
47
49
  恒产出一次死亡通知,与结果通知相互独立;
48
- - 每 notifyId 至多一条通知(record 幂等去重),重放不重复发声。
50
+ - 每 notifyId 至多一条通知(投递日志按 id 幂等判重),重放不重复发声。
49
51
 
50
- ## B-ledger 账本存储
52
+ ## 投递日志(delivery-port)
51
53
 
52
- 通知落账与恢复不依赖独立磁盘文件,复用 pi session 文件本身:
54
+ 通知记录与恢复不依赖独立磁盘文件,复用 pi session 文件本身(pi session append-only
55
+ 契约:entry 只追加不删改,compaction 不清 entry):
53
56
 
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
- 单实例,互不重复投递。
57
+ - **outbox entry = session 文件自定义 entry**:通知经 `@zhushanwen/pi-delivery-port`
58
+ 的 `port.deliver` 提交(id = notifyId 幂等键,managed-session-notify 外部通道逐条),
59
+ 受理前先 `pi.appendEntry` 写 outbox entry(delivery-port-outbox,plain custom entry
60
+ 不进 LLM 上下文)——先落盘后投递,进程崩溃后债权不丢。
61
+ - **送达回执处理完毕**:注入后 pi 侧出现携带该 id 的 custom_message,port 订阅
62
+ message_end 按 details-id 凭据命中 → 写 ack entry(delivery-port-ack)终局。
63
+ - **session_start 差集重放**:每次 session_start 扫投递日志 −(对话记录已送达 ∪ ack)
64
+ 差集——未处理的债权重新投递,已处理完毕的静默;按 notifyId 幂等去重,重放不重复发声。
65
+ - 投递端口装配在 `@zhushanwen/pi-base-tool-enhance` 的 session_start(infrastructure
66
+ 档必载),本扩展零装配义务——port 恒可用;缺席仅覆盖装配异常窗口(warn + 弃投该条,
67
+ 下次 session_start 重试装配)。
64
68
 
65
69
  ## 排查入口
66
70
 
67
71
  | 症状 | 排查路径 |
68
72
  |------|---------|
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` 留痕归因 |
73
+ | 未收到完成通知 | ① 确认 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) |
74
+ | pending 列表残留 type 'session' 条目 | register 由 send/create 带 prompt 的 arm 写入,正常由 watch 应答逐条 unregister。残留 = watch 应答未达(pi 进程被杀 / runtime 重启 / 通道折叠 cancelled);这些活跃 register 由下次 session_start 收尾路径逐条重开 watch(入参 = entry id 即 notifyId),应答到达即静默注销,无需手工处理 |
75
+ | 同一完成重复通知 | 不应发生——投递日志按 notifyId 判重、死亡槽 (sessionId, deathSeq) 同键只发首条。若复现即协议漂移,查扩展日志 `delivery port rejected notification` / `notify delivery failed` 留痕归因 |
72
76
 
73
77
  ## 工具速查
74
78
 
@@ -80,3 +84,5 @@ runtime 查得到时附 `Full transcript: <sessionFilePath>` 指针行。
80
84
  | `list_my_sessions` | 列本 agent 管理的 session(顺带回填 label 缓存,修复重启后的文案 label 降级) |
81
85
  | `get_session_status` | 查子 session 状态与模型信息 |
82
86
  | `abort_session` | 中止运行中的子 session(终态 stopped) |
87
+ | `archive_session` | 归档收尾已完成的子 session(AI 可主动调用;生成中会话报错 archive_generating,先 abort 或等完成;成功后 open watch 收 'exited',用户可从归档区恢复) |
88
+ | `delete_session` | 彻底删除子 session(仅用户明示不可恢复删除时调用——USER-INITIATED ONLY;模糊整理词归 archive_session;成功后 open watch 收 'deleted',不可恢复) |
@@ -12,8 +12,8 @@ import { expect, vi, type Mock } from "vitest";
12
12
 
13
13
  import registerExtension from "../../index.ts";
14
14
 
15
- /** registerTool 捕获的注册工具形态(execute 以六工具用例的固定五参形态调用) */
16
- export interface RegisteredTool { // oe-exempt:20260930:test:测试 harness 契约——多测试文件的断言面引用其字段类型
15
+ /** registerTool 捕获的注册工具形态(execute 以八工具用例的固定五参形态调用) */
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
 
@@ -105,7 +105,7 @@ function findTool(h: Pick<ExtensionHarness, "registered">, name: string): Regist
105
105
  return t;
106
106
  }
107
107
 
108
- /** 跑一次工具(callId/params 形态与六工具用例一致:双 undefined + harness ctx) */
108
+ /** 跑一次工具(callId/params 形态与八工具用例一致:双 undefined + harness ctx) */
109
109
  export async function runToolCall(
110
110
  h: Pick<ExtensionHarness, "registered" | "ctx">,
111
111
  name: string,
@@ -1,5 +1,5 @@
1
1
  // logger-mock.ts — session-manager 测试共享的 pi-extension-logger 模块 mock
2
- //(notify-ledger-discipline / tool-non-json-response / watch-orchestration 三份
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-ledger.ts / extension-harness helper)。
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 集(断言面经 getLoggerMock() 取用) */
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
- /** 断言面:取本测试文件的 logger spy(与 mock 模块内 getLogger() 返回值同引用) */
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 — 重启收口腿(notify-once D7③ / U4)。
2
- // 断言面:活跃 type 'session' register 重开 watch(入参 = entry id 即 notifyId)/
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
- // 收口腿 label 降级源 = register 三键 name。
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
- const LEDGER_SLOT_KEY = Symbol.for("@zhushanwen/subagent-core.notifyLedger");
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 的收口腿驱动(fail-closed / label 降级两用例共用前置):
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, LEDGER_SLOT_KEY);
88
+ Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
87
89
  });
88
90
 
89
- describe("session_start 重启收口腿(D7③)", () => {
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(杀父腿,触发器②批量转移先行)→ 即时静默 unregister
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' 腿:静默注销(活跃集回归基线)", async () => {
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("收口腿 label 降级源 = register 三键 name(D9 label 两路径的恢复路径)", async () => {
134
- Reflect.set(globalThis, LEDGER_SLOT_KEY, {
135
- current: { record: vi.fn(() => true), compactionCheck: () => 0 },
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(record).toHaveBeenCalledTimes(1);
143
- expect(String(record.mock.calls[0][1])).toContain('Managed session "Alpha" (child-x)');
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
  });
@@ -1,7 +1,8 @@
1
1
  // tool-error-handling.test.ts — U5-A4: null/undefined returns → cancelled throw; exceptions → propagate;
2
- // {error} respond → throw(W4 throw 范式:pi agent-loop 仅在 execute throw 时置 isError:true——
3
- // pi-agent-core dist/agent-loop.js:453-483 executePreparedToolCall 正常 return 硬编码 isError:false;
4
- // 语义登记 PS-56)
2
+ // {error} respond → throw(W4 throw 范式:execute throw → pi agent-loop catch 置 isError:true——
3
+ // pi-agent-core dist/agent-loop.js executePreparedToolCall,1.0.0 实读 catch :581-588;1.0.0 起
4
+ // 正常 return 的 isError 也被尊重(同文件 :579 `isError: result.isError === true`,0.84.4 才是
5
+ // 硬编码 false)——本包失败恒走 throw,两版语义等价(语义登记 PS-56)
5
6
 
6
7
  import { describe, it, expect, vi, beforeEach } from "vitest";
7
8
  import { createToolHarness, expectToolRejects, runToolForText } from "./helpers/extension-harness.ts";
@@ -58,6 +59,23 @@ describe("U5-A4 tool-error-handling", () => {
58
59
  );
59
60
  });
60
61
 
62
+ it("runtime respond 携带 {error, code}(ArchiveError 码透传)→ throw 文本含结构化错误码(决策 5:archive_generating 对模型可见)", async () => {
63
+ await expectToolRejects(
64
+ createToolHarness(
65
+ vi.fn().mockResolvedValue(
66
+ JSON.stringify({
67
+ error: "会话正在生成中,无法归档 (sessionId=s1)",
68
+ code: "archive_generating",
69
+ hint: "abort first or wait for completion, then retry archive",
70
+ }),
71
+ ),
72
+ ),
73
+ "archive_session",
74
+ { sessionId: "s1" },
75
+ /archive_generating/,
76
+ );
77
+ });
78
+
61
79
  it("runtime respond 正常 JSON → 成功返回(不含 isError 字段)", async () => {
62
80
  const result = await runToolForText(
63
81
  createToolHarness(vi.fn().mockResolvedValue(JSON.stringify({ queued: true }))),
@@ -68,7 +86,7 @@ describe("U5-A4 tool-error-handling", () => {
68
86
  expect(JSON.parse(result.content[0].text)).toEqual({ queued: true });
69
87
  });
70
88
 
71
- it("all 6 tools handle null gracefully(cancelled throw)", async () => {
89
+ it("all 8 tools handle null gracefully(cancelled throw)", async () => {
72
90
  const { registered, ctx } = createToolHarness(vi.fn().mockResolvedValue(null));
73
91
  for (const tool of registered) {
74
92
  const params = tool.name === "create_managed_session"
@@ -5,8 +5,10 @@
5
5
  // isError)——runtime 协议漂移对 agent 不可辨。D8
6
6
  //(docs/architecture/ext-simplify-17-shared-extraction.md §3.3,有意微变)统一为
7
7
  // 对齐 plugin-bridge 形态:logger.error 留痕(callMarkerRpc 原语经注入的 log 承担)
8
- // + 错误路径 throw(W4 范式:pi agent-loop 仅在 execute throw 时置 isError:true——
9
- // pi-agent-core dist/agent-loop.js:453-483,语义登记 PS-56)。
8
+ // + 错误路径 throw(W4 范式:execute throw → pi agent-loop catch 置 isError:true——
9
+ // pi-agent-core dist/agent-loop.js executePreparedToolCall,1.0.0 实读 catch :581-588;
10
+ // 1.0.0 起正常 return 的 isError 也被尊重(同文件 :579,0.84.4 才硬编码 false),本包
11
+ // 失败恒走 throw、两版语义等价(语义登记 PS-56)。
10
12
 
11
13
  import { describe, it, expect, vi, beforeEach } from "vitest";
12
14
  // logger mock helper 必须先于 extension-harness(其 import 链触发被 mock 模块加载)