@zhushanwen/pi-session-reader 0.6.0 → 0.7.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 +100 -0
- package/package.json +7 -4
- package/src/__tests__/cross-package-subagent-core.test.ts +36 -44
- package/src/__tests__/entry-anchor.test.ts +226 -0
- package/src/__tests__/env.test.ts +54 -54
- package/src/__tests__/execution-tree.test.ts +6 -0
- package/src/__tests__/family-zcode.test.ts +255 -0
- package/src/__tests__/family.test.ts +1 -1
- package/src/__tests__/find.test.ts +24 -1
- package/src/__tests__/index.test.ts +1 -1
- package/src/__tests__/parser.test.ts +1 -2
- package/src/__tests__/real-data.ts +22 -9
- package/src/__tests__/render.test.ts +1 -2
- package/src/__tests__/result.test.ts +2 -2
- package/src/__tests__/roots.test.ts +1 -1
- package/src/__tests__/subagents.test.ts +1 -4
- package/src/__tests__/tool-handler.test.ts +76 -85
- package/src/__tests__/toolcall.test.ts +1 -1
- package/src/__tests__/tree.test.ts +1 -1
- package/src/__tests__/turns.test.ts +1 -2
- package/src/__tests__/zcode-manifest.test.ts +361 -0
- package/src/__tests__/zcode-routing.test.ts +751 -0
- package/src/__tests__/zcode-whitelist.test.ts +119 -0
- package/src/core/execution-tree.ts +3 -1
- package/src/core/family.ts +80 -1
- package/src/core/render.ts +2 -3
- package/src/core/toolcall.ts +3 -4
- package/src/core/tree.ts +1 -1
- package/src/core/turns.ts +1 -1
- package/src/core/units.ts +14 -0
- package/src/discovery/entry-anchor.ts +108 -0
- package/src/discovery/env.ts +41 -41
- package/src/discovery/find.ts +1 -1
- package/src/discovery/roots.ts +2 -2
- package/src/discovery/session-header.ts +30 -69
- package/src/discovery/subagents.ts +9 -2
- package/src/discovery/whitelist.ts +87 -0
- package/src/discovery/workflows.ts +1 -1
- package/src/discovery/zcode-manifest.ts +277 -0
- package/src/doctor.ts +5 -70
- package/src/index.ts +9 -2
- package/src/result-action.ts +15 -6
- package/src/search-across.ts +2 -5
- package/src/tool-format.ts +455 -0
- package/src/tool-handler.ts +321 -325
- package/src/zcode-anchor-classify.ts +60 -0
- package/vitest.config.ts +2 -4
- package/src/core/parser.ts +0 -167
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @zhushanwen/pi-session-reader
|
|
2
|
+
|
|
3
|
+
按语义结构读取 pi session 文件(对话历史)的工具:`session_read` 提供 11 个 action,覆盖 session 定位、fork/subagent/workflow 家族追踪、渐进式阅读(概览 → 单 turn → 全文)、全文/跨 session 搜索、素材提取、workflow run 概览、subagent 结果获取与环境自检——不需要直接读原始 `.jsonl`。
|
|
4
|
+
|
|
5
|
+
## 提供的工具
|
|
6
|
+
|
|
7
|
+
### `session_read`
|
|
8
|
+
|
|
9
|
+
所有 action 共用一套参数,按 action 取用:
|
|
10
|
+
|
|
11
|
+
| action | 关键参数 | 用途 |
|
|
12
|
+
|--------|---------|------|
|
|
13
|
+
| `find` | `query`(uuid 片段 / 文件名 / name 关键词 / `"recent"`) | 定位 session;可选 `cwd` / `source`(`main` / `subagent`)/ `limit`(默认 20);零匹配返回提示不报错 |
|
|
14
|
+
| `family` | `session` | fork 父子 / subagent / workflow 关系;`recursive: true` 返回任意深度嵌套的执行树 |
|
|
15
|
+
| `outline` | `session` | turn 级概览(约 1500 token);`granularity: "entry"` 平铺到 entry、`allBranches` 含废弃侧分支 |
|
|
16
|
+
| `expand` | `session` + `turn`(如 `"T013"`) | 单 turn 的 entry 列表 |
|
|
17
|
+
| `detail` | `session` + `turns`(如 `"T013-T015"` 或 `"T013"`) | turn 全文;`includeToolResult` / `includeThinking` 控制是否包含噪音(默认省略) |
|
|
18
|
+
| `search` | `session` + `pattern` | 全文 grep(子串或正则);`session` 可传逗号分隔 ≤10 个完整 id 做跨 session 搜索;`scope` 过滤 `all`(默认)/ `user` / `assistant` / `toolResult` |
|
|
19
|
+
| `export` | `session` | 物化到文件;`format`: `outline`(默认)/ `full` / `family`;`includeToolResult` / `allBranches` 亦作用于本 action |
|
|
20
|
+
| `extract` | `session` + `what` | 按类型抽取素材:`user-messages` / `commands` / `files` / `commits` / `tool-results`;`tool` 按工具名过滤 commands/tool-results;可选 `turns` 限定范围(按全量分段序号,含 compaction 周期/旁支,与 outline 的 leaf turn 序号不一致) |
|
|
21
|
+
| `workflow` | `session` | workflow run 概览(status / budget / steps);`runId` 聚焦单个 run;step 的 call sessionId 可跳转 outline/detail |
|
|
22
|
+
| `result` | `session` | 取 subagent 最终结果正文(与其完成通知同内容);单个 id 或逗号分隔 ≤10 个批量;`limit` 限每条字符数(默认 8000,超长截断并附全文文件指引) |
|
|
23
|
+
| `doctor` | — | 环境自检:host 环境判定(纯 pi / taiji)+ 候选 session 根目录诊断;`includeSubagents: true` 附带扫描 subagent 根文件数 |
|
|
24
|
+
|
|
25
|
+
`session` 参数支持四种形态:完整 uuid、uuid 片段(如 `e6c96`)、subagent record id(`sa-xxx` 精确反查)、`.jsonl` 绝对路径(支持 `~` 前缀);`#` 前缀自动剥离。
|
|
26
|
+
|
|
27
|
+
**渐进式阅读**:`outline`(概览)→ `expand`(单 turn)→ `detail`(全文),默认省略 toolResult / thinking 噪音。不用于当前 session(宿主已提供当前会话访问),也不用于编辑 session(pi 有 `/resume` `/fork`)。
|
|
28
|
+
|
|
29
|
+
## TUI 附加能力(仅 TUI 模式)
|
|
30
|
+
|
|
31
|
+
- `/session-pick` 命令:交互式选择当前 cwd 目录下的 session
|
|
32
|
+
- `#` 前缀自动补全:编辑器输入 `#` 弹出当前目录的 session 引用补全
|
|
33
|
+
|
|
34
|
+
RPC 模式(如 taiji 子进程)自动跳过 TUI 注册,`session_read` 工具本身不受影响。
|
|
35
|
+
|
|
36
|
+
## 核心机制
|
|
37
|
+
|
|
38
|
+
- **分层**:`tool-handler.ts` 纯逻辑 handler(agentDir / 环境信号注入,零 pi 依赖,可单测)+ `index.ts` pi 注册层(registerTool + TUI 挂载)
|
|
39
|
+
- **多根发现与降级**:session 根按 [live](当前 session 目录)→ [default] → [legacy] 及 subagent 根多根合并扫描;`doctor` 可诊断实际生效的根
|
|
40
|
+
- **session 解析三形态**:绝对路径直读首行 header → `sa-` 前缀经 record manifest 精确反查 → 片段模糊匹配(多匹配返回消歧候选列表而非报错)
|
|
41
|
+
- **错误契约**:handler 抛出含 👉 恢复指引的 Error,pi 置 `isError: true`,模型可按指引一步修正重试
|
|
42
|
+
- **成本控制**:标题元数据走 pi `SessionManager.listAll`(仅当前 cwd 目录,惰性调用);search 长扫描可被 AbortSignal 中断
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# npm 方式(正式)
|
|
48
|
+
pi install npm:@zhushanwen/pi-session-reader
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
本地开发可用 `--extension` 直接加载:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pi --extension <repo>/extensions/universal/session-reader/index.ts "<prompt>"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 使用示例
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
// 定位 session(片段匹配,多命中返回候选列表)
|
|
61
|
+
{ "action": "find", "query": "e6c96" }
|
|
62
|
+
|
|
63
|
+
// 概览 → 深读某段 turn
|
|
64
|
+
{ "action": "outline", "session": "e6c96" }
|
|
65
|
+
{ "action": "detail", "session": "e6c96", "turns": "T013-T015" }
|
|
66
|
+
|
|
67
|
+
// 追踪 fork / subagent 家族(嵌套执行树)
|
|
68
|
+
{ "action": "family", "session": "e6c96", "recursive": true }
|
|
69
|
+
|
|
70
|
+
// 取 subagent 最终结果(批量 ≤10)
|
|
71
|
+
{ "action": "result", "session": "sa-abc123, sa-def456" }
|
|
72
|
+
|
|
73
|
+
// 跨 session 搜索用户消息
|
|
74
|
+
{ "action": "search", "session": "<id1>,<id2>", "pattern": "登录逻辑", "scope": "user" }
|
|
75
|
+
|
|
76
|
+
// 抽取全部 bash 命令
|
|
77
|
+
{ "action": "extract", "session": "e6c96", "what": "commands", "tool": "bash" }
|
|
78
|
+
|
|
79
|
+
// 环境自检
|
|
80
|
+
{ "action": "doctor" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 文件结构
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
session-reader/
|
|
87
|
+
├── index.ts # 入口 — re-export src/index.ts
|
|
88
|
+
└── src/
|
|
89
|
+
├── index.ts # pi 注册层(registerTool + promptGuidelines + TUI 命令/补全挂载)
|
|
90
|
+
├── tool-handler.ts # 纯逻辑 handler(action 分发 + session 解析 + 各 action 编排)
|
|
91
|
+
├── search-across.ts # search 管线(单 session / 跨 session)
|
|
92
|
+
├── extract.ts # extract 预设(user-messages/commands/files/commits/tool-results)
|
|
93
|
+
├── result-action.ts # result action(subagent 最终结果,批量 ≤10)
|
|
94
|
+
├── doctor.ts # doctor action(host 环境判定 + 根目录诊断)
|
|
95
|
+
├── no-match.ts # find 零匹配自检行
|
|
96
|
+
├── handler-utils.ts # 低层小工具(pad/err/stripHash/turn 解析等)
|
|
97
|
+
├── core/ # 解析与渲染(parser / tree / turns / family / execution-tree / workflow / render / toolcall)
|
|
98
|
+
├── discovery/ # session 发现(roots / find / subagents / workflows / env / session-header)
|
|
99
|
+
└── tui/ # TUI 挂载(session-pick 命令 + # 补全 provider)
|
|
100
|
+
```
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
|
-
"
|
|
6
|
+
"taiji": {
|
|
7
7
|
"role": "universal"
|
|
8
8
|
},
|
|
9
9
|
"pi": {
|
|
@@ -16,12 +16,15 @@
|
|
|
16
16
|
"pi-package"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zhushanwen/pi-
|
|
19
|
+
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
20
|
+
"@zhushanwen/pi-ext-guards": "0.4.1",
|
|
21
|
+
"@zhushanwen/session-core": "0.11.0",
|
|
22
|
+
"@zhushanwen/zcode-session-source": "0.2.0"
|
|
20
23
|
},
|
|
21
24
|
"devDependencies": {
|
|
22
25
|
"@vitest/coverage-v8": "^4.1.9",
|
|
23
26
|
"vitest": "^4.1.8",
|
|
24
|
-
"@zhushanwen/subagent-core": "0.
|
|
27
|
+
"@zhushanwen/subagent-core": "0.11.1"
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
27
30
|
"index.ts",
|
|
@@ -20,20 +20,21 @@ import {
|
|
|
20
20
|
} from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* W4 跨包集成测试(
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* W4 跨包集成测试([collect 退役] 改造保留:原「E1 落标出口产出 manifest」驱动面
|
|
24
|
+
* 随 sync 批机制删除,现行真实投影出口 = store 重建通道 rebuildIndexes——manifest
|
|
25
|
+
* 是可丢缓存,boot 全量腿按磁盘子文件锚幂等补缺)。「subagent-core 真实 tmpdir
|
|
26
|
+
* 产出磁盘状态 → session-reader result action sa- id 反查,不 mock manifest 写入」
|
|
27
|
+
* 的跨包契约保持不变。
|
|
26
28
|
*
|
|
27
|
-
* 与 result.test.ts 的分工:result.test.ts 的 fixture **手工预写** manifest
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* session-reader 侧零 mock 读同一 tmpdir 断言反查闭环。
|
|
29
|
+
* 与 result.test.ts 的分工:result.test.ts 的 fixture **手工预写** manifest;本文件
|
|
30
|
+
* manifest 内容必须来自投影代码路径——subagent-core 侧真实驱动(真实 RecordStore
|
|
31
|
+
* 落盘 register/轮终 entry → initSession 内 orphan 覆写真实跑 → rebuildIndexes
|
|
32
|
+
* derivedManifestRecord 投影真写 records/<sa-id>.json),session-reader 侧零 mock
|
|
33
|
+
* 读同一 tmpdir 断言反查闭环。
|
|
33
34
|
*
|
|
34
35
|
* 构造形态取自 subagent-core sync-collect-recovery.test.ts 的 kill -9 同构主用例
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* (形态同构是 v1 教训「种子绕过真实链路 = 假绿」的执行):子 session 文件在其
|
|
37
|
+
* 基础上补 message entries(result action 的正文提取源)。
|
|
37
38
|
*
|
|
38
39
|
* mock 面最小:仅构造 SubagentService 所需的最小 pi 形状(appendEntry 真写主文件,
|
|
39
40
|
* 其余 no-op stub)——零 vi.mock、notifier/store/config 全真实实现,agentDir 全部
|
|
@@ -48,9 +49,9 @@ const ENV_KEYS_TO_STRIP = [
|
|
|
48
49
|
'PI_SUBAGENT_DEPTH',
|
|
49
50
|
'PI_SUBAGENT_ROOT_CWD',
|
|
50
51
|
'PI_SUBAGENT_FORK_DEPTH',
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
52
|
+
'TAIJI_SUBAGENT_RELAY_SOCKET',
|
|
53
|
+
'TAIJI_SUBAGENT_RELAY_NODE',
|
|
54
|
+
'TAIJI_SUBAGENT_RELAY_SCRIPT',
|
|
54
55
|
] as const
|
|
55
56
|
|
|
56
57
|
const ROOT_SESSION = 'root-cross-pkg'
|
|
@@ -102,8 +103,6 @@ function memberRecord(overrides: Partial<SubagentRecord> & { id: string }): Suba
|
|
|
102
103
|
result: undefined,
|
|
103
104
|
error: undefined,
|
|
104
105
|
sessionFile: undefined,
|
|
105
|
-
chatMode: false,
|
|
106
|
-
collectMode: 'sync',
|
|
107
106
|
...overrides,
|
|
108
107
|
}
|
|
109
108
|
}
|
|
@@ -118,7 +117,7 @@ interface MemberSeed {
|
|
|
118
117
|
model: string
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
describe('跨包集成:subagent-core
|
|
120
|
+
describe('跨包集成:subagent-core 重建 manifest → session-reader result 反查(W4)', () => {
|
|
122
121
|
let agentDir: string
|
|
123
122
|
let mainFile: string
|
|
124
123
|
let services: SubagentService[]
|
|
@@ -224,21 +223,11 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
224
223
|
)
|
|
225
224
|
}
|
|
226
225
|
|
|
227
|
-
/**
|
|
228
|
-
*
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (Date.now() - start > timeoutMs) {
|
|
233
|
-
throw new Error(`cross-pkg: condition not met within ${timeoutMs}ms`)
|
|
234
|
-
}
|
|
235
|
-
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** 全链路驱动:种子 → 恢复 service(orphan 覆写)→ E1(落标出口真写 manifest)。
|
|
240
|
-
* 返回子 session 文件路径表。 */
|
|
241
|
-
async function driveSyncBatchRecovery(members: MemberSeed[]): Promise<Map<string, string>> {
|
|
226
|
+
/** 全链路驱动:种子 → 恢复 service(orphan 覆写)→ rebuildIndexes(现行 boot
|
|
227
|
+
* 全量重建通道——derivedManifestRecord 投影真写 manifest)。返回子 session 文件
|
|
228
|
+
* 路径表。[collect 退役] 原 E1 落标出口(recoverSyncCollectBatch →
|
|
229
|
+
* appendBatchFinalizedEntry)随 sync 批机制删除。 */
|
|
230
|
+
async function driveManifestRebuild(members: MemberSeed[]): Promise<Map<string, string>> {
|
|
242
231
|
const childFiles = new Map<string, string>()
|
|
243
232
|
for (const m of members) {
|
|
244
233
|
const childFile = writeChildSessionFile(m)
|
|
@@ -246,12 +235,15 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
246
235
|
seedRoundTerminalEntries(m, childFile)
|
|
247
236
|
}
|
|
248
237
|
const service = makeRecoveryService()
|
|
249
|
-
service.
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
238
|
+
service.rebuildIndexes()
|
|
239
|
+
// derivedManifestRecord 投影为同步落盘——全成员 manifest 即刻可见(W3 投影
|
|
240
|
+
// 代码路径的产物,非手工预写)。
|
|
241
|
+
for (const m of members) {
|
|
242
|
+
expect(
|
|
243
|
+
fs.existsSync(path.join(getSubagentRecordsDir(agentDir, agentDir), `${m.id}.json`)),
|
|
244
|
+
`manifest of ${m.id}`,
|
|
245
|
+
).toBe(true)
|
|
246
|
+
}
|
|
255
247
|
return childFiles
|
|
256
248
|
}
|
|
257
249
|
|
|
@@ -263,12 +255,12 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
263
255
|
assistantParts: ['part one', 'part two'],
|
|
264
256
|
model: 'prov/round-m',
|
|
265
257
|
}
|
|
266
|
-
const childFiles = await
|
|
258
|
+
const childFiles = await driveManifestRebuild([m])
|
|
267
259
|
|
|
268
|
-
// manifest 内容断言(
|
|
269
|
-
// sessionFile 来自 W1 投影扩展。[U2/U3 两态桥接]
|
|
270
|
-
// idle
|
|
271
|
-
|
|
260
|
+
// manifest 内容断言(rebuildIndexes → derivedManifestRecord 投影路径的产物,
|
|
261
|
+
// 非手工预写):sessionFile 来自 W1 投影扩展。[U2/U3 两态桥接] 恢复链覆写后成员
|
|
262
|
+
// 处于 idle(重建单规则)、无旧终态遗留位 → legacy status 如实投影 "running"
|
|
263
|
+
//(legacyManifestStatusFields 单点派生,session-reader 视角的活跃成员)。
|
|
272
264
|
const manifest = JSON.parse(
|
|
273
265
|
fs.readFileSync(path.join(getSubagentRecordsDir(agentDir, agentDir), 'sa-cross-1.json'), 'utf-8'),
|
|
274
266
|
) as Record<string, unknown>
|
|
@@ -303,7 +295,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
303
295
|
{ id: 'sa-cross-a', resultText: 'alpha final answer', assistantParts: ['alpha final answer'], model: 'prov/ma' },
|
|
304
296
|
{ id: 'sa-cross-b', resultText: 'beta final answer', assistantParts: ['beta final answer'], model: 'prov/mb' },
|
|
305
297
|
]
|
|
306
|
-
await
|
|
298
|
+
await driveManifestRebuild(members)
|
|
307
299
|
|
|
308
300
|
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, { agentDir })
|
|
309
301
|
const text = r.content[0]?.text ?? ''
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { SUBAGENT_RECORD_CUSTOM_TYPE } from '@zhushanwen/subagent-core'
|
|
7
|
+
|
|
8
|
+
import { findZcodeEntryAnchor, type ZcodeAnchor } from '../discovery/entry-anchor.js'
|
|
9
|
+
|
|
10
|
+
// ============================================================
|
|
11
|
+
// 变体 E(设计 §4 场景 1a):manifest 缺席时 entry 兜底定位(F16 每轮覆写 → 取文件
|
|
12
|
+
// 顺序末条,collectLastRecordEntries 同构)。
|
|
13
|
+
//
|
|
14
|
+
// 单元边界:本文件只测「定位」(sa-id → sessionRef 锚);「读到 sess-B 行集内容」的
|
|
15
|
+
// 库读取断言属 U9 集成测试(场景 1a 变体 E 全量断言),此处不引入 sqlite 依赖。
|
|
16
|
+
// sess-A / sess-B 本身即两个可区分的锚值——取首条或合并的实现会直接失败。
|
|
17
|
+
// ============================================================
|
|
18
|
+
|
|
19
|
+
const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
|
|
20
|
+
|
|
21
|
+
let dir: string
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dir = mkdtempSync(join(tmpdir(), 'entry-anchor-test-'))
|
|
25
|
+
})
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** subagent-record custom entry 行(顶层 id = entry id;data.id = record id 即 sa-id)。 */
|
|
31
|
+
function recordLine(
|
|
32
|
+
entryId: string,
|
|
33
|
+
saId: string,
|
|
34
|
+
sessionRef: Record<string, string>,
|
|
35
|
+
v = 1,
|
|
36
|
+
): string {
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
type: 'custom',
|
|
39
|
+
customType: 'subagent-record',
|
|
40
|
+
id: entryId,
|
|
41
|
+
parentId: null,
|
|
42
|
+
data: { v, id: saId, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' } },
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** pi 形态 message 行(真实感填充)。 */
|
|
47
|
+
function messageLine(entryId: string, text: string): string {
|
|
48
|
+
return JSON.stringify({
|
|
49
|
+
type: 'message',
|
|
50
|
+
id: entryId,
|
|
51
|
+
parentId: null,
|
|
52
|
+
message: { role: 'user', content: text },
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 缺 dbPath 的 sessionRef(pi record 形态:只有 sessionId 单键)。 */
|
|
57
|
+
const REF_NO_DBPATH = { sessionId: 'sess-pi-x' }
|
|
58
|
+
|
|
59
|
+
/** 缺 sessionId 的 sessionRef(半键)。 */
|
|
60
|
+
const REF_NO_SESSION_ID = { dbPath: DB_PATH }
|
|
61
|
+
|
|
62
|
+
function writeMainSession(name: string, lines: string[]): string {
|
|
63
|
+
mkdirSync(dir, { recursive: true })
|
|
64
|
+
const file = join(dir, name)
|
|
65
|
+
writeFileSync(file, lines.join('\n') + '\n', 'utf8')
|
|
66
|
+
return file
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('变体 E:同 sa-id 多条 entry 取文件顺序末条(F16 覆写)', () => {
|
|
70
|
+
it('首条 sess-A / 末条 sess-B → 命中 sess-B 且 ≠ sess-A(取首条/合并必失败)', async () => {
|
|
71
|
+
const file = writeMainSession('main.jsonl', [
|
|
72
|
+
JSON.stringify({ type: 'session', id: 'main-1', cwd: '/tmp/proj' }),
|
|
73
|
+
messageLine('m1', 'dispatch subagent'),
|
|
74
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
75
|
+
messageLine('m2', 'round 1 done'),
|
|
76
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
80
|
+
|
|
81
|
+
// 非平凡断言:两个不同锚值使「末条 vs 首条」结果可区分
|
|
82
|
+
expect(anchor).toBeDefined()
|
|
83
|
+
expect(anchor?.sessionId).toBe('sess-B')
|
|
84
|
+
expect(anchor?.sessionId).not.toBe('sess-A')
|
|
85
|
+
expect(anchor?.dbPath).toBe(DB_PATH)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('多条不同 sa-id 交错:只命中目标 id 的末条,不串锚', async () => {
|
|
89
|
+
const file = writeMainSession('main.jsonl', [
|
|
90
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
91
|
+
recordLine('e1', 'sa-1', { sessionId: 'sa1-r1', dbPath: DB_PATH }),
|
|
92
|
+
recordLine('e2', 'sa-2', { sessionId: 'sa2-r1', dbPath: DB_PATH }),
|
|
93
|
+
recordLine('e3', 'sa-1', { sessionId: 'sa1-r2', dbPath: DB_PATH }),
|
|
94
|
+
recordLine('e4', 'sa-2', { sessionId: 'sa2-r2', dbPath: DB_PATH }),
|
|
95
|
+
recordLine('e5', 'sa-1', { sessionId: 'sa1-r3', dbPath: DB_PATH }),
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
expect((await findZcodeEntryAnchor([file], 'sa-1'))?.sessionId).toBe('sa1-r3')
|
|
99
|
+
expect((await findZcodeEntryAnchor([file], 'sa-2'))?.sessionId).toBe('sa2-r2')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('行级容错(best-effort,不因一行坏 JSON 失败整文件)', () => {
|
|
104
|
+
it('坏行穿插 + 末行半行:正常命中末条完整条目', async () => {
|
|
105
|
+
const file = writeMainSession('main.jsonl', [
|
|
106
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
107
|
+
'not-json-at-all{{{',
|
|
108
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
109
|
+
'{"type":"custom","customType":"subagent-record","data":{"broken"',
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
113
|
+
expect(anchor?.sessionId).toBe('sess-A')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('形状校验窄而严:缺键条目不算命中,继续找更早条目', () => {
|
|
118
|
+
it('末条 sessionRef 缺 dbPath → 命中更早的完整条目', async () => {
|
|
119
|
+
const file = writeMainSession('main.jsonl', [
|
|
120
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
121
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
|
|
122
|
+
recordLine('e2', 'sa-1', REF_NO_DBPATH),
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
126
|
+
expect(anchor?.sessionId).toBe('sess-EARLY')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('全部条目缺双键 → not-found(undefined)', async () => {
|
|
130
|
+
const file = writeMainSession('main.jsonl', [
|
|
131
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
132
|
+
recordLine('e1', 'sa-1', REF_NO_DBPATH),
|
|
133
|
+
recordLine('e2', 'sa-1', REF_NO_SESSION_ID),
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('sessionRef 键为空串 → 不算命中(非空 string 才算锚)', async () => {
|
|
140
|
+
const file = writeMainSession('main.jsonl', [
|
|
141
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
142
|
+
recordLine('e1', 'sa-1', { sessionId: '', dbPath: DB_PATH }),
|
|
143
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-A', dbPath: '' }),
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('data.v ≠ 1 的条目不算命中,更早 v=1 条目仍可命中', async () => {
|
|
150
|
+
const file = writeMainSession('main.jsonl', [
|
|
151
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
152
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
153
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-V2', dbPath: DB_PATH }, 2),
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
157
|
+
expect(anchor?.sessionId).toBe('sess-A')
|
|
158
|
+
expect(anchor?.sessionId).not.toBe('sess-V2')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('无关条目不命中', () => {
|
|
163
|
+
it('非 subagent-record 的 custom entry、id 不匹配的 record 均跳过', async () => {
|
|
164
|
+
const todoLine = JSON.stringify({
|
|
165
|
+
type: 'custom',
|
|
166
|
+
customType: 'todo',
|
|
167
|
+
id: 't1',
|
|
168
|
+
parentId: null,
|
|
169
|
+
data: { v: 1, id: 'sa-1', engineHandle: { sessionRef: { sessionId: 'sess-TODO', dbPath: DB_PATH } } },
|
|
170
|
+
})
|
|
171
|
+
const file = writeMainSession('main.jsonl', [
|
|
172
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
173
|
+
todoLine,
|
|
174
|
+
recordLine('e1', 'sa-other', { sessionId: 'sess-OTHER', dbPath: DB_PATH }),
|
|
175
|
+
])
|
|
176
|
+
|
|
177
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
178
|
+
expect((await findZcodeEntryAnchor([file], 'sa-other'))?.sessionId).toBe('sess-OTHER')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('候选文件列表:顺序回退 + 命中即止(越界语义由调用方承载)', () => {
|
|
183
|
+
it('前一候选未命中 → 后一候选命中', async () => {
|
|
184
|
+
const empty = writeMainSession('empty.jsonl', [JSON.stringify({ type: 'session', id: 's0' })])
|
|
185
|
+
const withAnchor = writeMainSession('with-anchor.jsonl', [
|
|
186
|
+
JSON.stringify({ type: 'session', id: 'main-2' }),
|
|
187
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
const anchor: ZcodeAnchor | undefined = await findZcodeEntryAnchor([empty, withAnchor], 'sa-1')
|
|
191
|
+
expect(anchor?.sessionId).toBe('sess-B')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('前一候选命中即止:不跨文件合并(后一候选的更新条目不覆盖)', async () => {
|
|
195
|
+
const first = writeMainSession('first.jsonl', [
|
|
196
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
197
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
198
|
+
])
|
|
199
|
+
const second = writeMainSession('second.jsonl', [
|
|
200
|
+
JSON.stringify({ type: 'session', id: 'main-2' }),
|
|
201
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
const anchor = await findZcodeEntryAnchor([first, second], 'sa-1')
|
|
205
|
+
expect(anchor?.sessionId).toBe('sess-A')
|
|
206
|
+
expect(anchor?.sessionId).not.toBe('sess-B')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('候选不可读(不存在)→ 跳过继续,全不可读 → not-found', async () => {
|
|
210
|
+
const withAnchor = writeMainSession('with-anchor.jsonl', [
|
|
211
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
212
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
expect((await findZcodeEntryAnchor([join(dir, 'missing.jsonl'), withAnchor], 'sa-1'))?.sessionId).toBe('sess-B')
|
|
216
|
+
expect(await findZcodeEntryAnchor([join(dir, 'missing.jsonl')], 'sa-1')).toBeUndefined()
|
|
217
|
+
expect(await findZcodeEntryAnchor([], 'sa-1')).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('协议字面量漂移守卫(跨包契约)', () => {
|
|
222
|
+
it('entry-anchor 本地 customType 字面量 === subagent-core 写侧单源', () => {
|
|
223
|
+
// 源码本地持有磁盘协议字符串(生产依赖面不引 subagent-core),此处锚定两侧同值
|
|
224
|
+
expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
|
|
225
|
+
})
|
|
226
|
+
})
|