@zhushanwen/pi-session-reader 0.6.1 → 0.8.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 +6 -3
- package/src/__tests__/entry-anchor.test.ts +370 -0
- package/src/__tests__/execution-tree.test.ts +12 -12
- package/src/__tests__/family-zcode.test.ts +255 -0
- package/src/__tests__/family.test.ts +1 -1
- package/src/__tests__/find.test.ts +32 -49
- package/src/__tests__/fixtures.ts +15 -0
- package/src/__tests__/parser.test.ts +37 -13
- package/src/__tests__/render.test.ts +22 -19
- package/src/__tests__/result.test.ts +2 -2
- package/src/__tests__/roots.test.ts +32 -34
- package/src/__tests__/subagents.test.ts +131 -108
- package/src/__tests__/tool-handler.test.ts +506 -153
- package/src/__tests__/toolcall.test.ts +1 -1
- package/src/__tests__/tree.test.ts +1 -1
- package/src/__tests__/turns.test.ts +21 -20
- package/src/__tests__/workflow.test.ts +319 -23
- 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 +1 -1
- package/src/core/toolcall.ts +2 -2
- package/src/core/tree.ts +1 -1
- package/src/core/turns.ts +1 -1
- package/src/core/workflow.ts +196 -6
- package/src/discovery/entry-anchor.ts +139 -0
- package/src/discovery/roots.ts +1 -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 +232 -22
- package/src/discovery/zcode-manifest.ts +277 -0
- package/src/index.ts +7 -0
- package/src/result-action.ts +14 -5
- package/src/search-across.ts +1 -1
- package/src/tool-format.ts +455 -0
- package/src/tool-handler.ts +400 -344
- package/src/zcode-anchor-classify.ts +74 -0
- package/src/__tests__/real-data.ts +0 -96
- 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -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/session-core": "0.11.0",
|
|
21
|
+
"@zhushanwen/zcode-session-source": "0.2.1",
|
|
22
|
+
"@zhushanwen/pi-ext-guards": "0.4.2"
|
|
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.12.0"
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
27
30
|
"index.ts",
|
|
@@ -0,0 +1,370 @@
|
|
|
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, SUBAGENT_RECORD_ENTRY_VERSION } 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
|
+
/**
|
|
57
|
+
* v2 终态条行(W1 D1:`{v:2, kind:"settled", id, engine?, engineHandle?, ...}`——
|
|
58
|
+
* 载荷形态对齐 subagent-core SubagentRecordSettledEntryData,锚取 engineHandle.sessionRef)。
|
|
59
|
+
*/
|
|
60
|
+
function v2SettledLine(
|
|
61
|
+
entryId: string,
|
|
62
|
+
saId: string,
|
|
63
|
+
opts: {
|
|
64
|
+
sessionRef?: Record<string, string>
|
|
65
|
+
engine?: string
|
|
66
|
+
} = {},
|
|
67
|
+
): string {
|
|
68
|
+
const data: Record<string, unknown> = {
|
|
69
|
+
v: 2,
|
|
70
|
+
kind: 'settled',
|
|
71
|
+
id: saId,
|
|
72
|
+
status: 'idle',
|
|
73
|
+
stopReason: 'end_turn',
|
|
74
|
+
endedAt: 123,
|
|
75
|
+
turns: 1,
|
|
76
|
+
totalTokens: 10,
|
|
77
|
+
model: undefined,
|
|
78
|
+
thinkingLevel: undefined,
|
|
79
|
+
}
|
|
80
|
+
if (opts.engine !== undefined) data.engine = opts.engine
|
|
81
|
+
if (opts.sessionRef !== undefined) {
|
|
82
|
+
data.engineHandle = { sessionRef: opts.sessionRef, poolKey: 'shared' }
|
|
83
|
+
}
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
type: 'custom',
|
|
86
|
+
customType: 'subagent-record',
|
|
87
|
+
id: entryId,
|
|
88
|
+
parentId: null,
|
|
89
|
+
data,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* v2 注册条行(`{v:2, kind:"registered", id, agent, task, ...}`——身份域字段,不携带
|
|
95
|
+
* engineHandle;设计 D1 裁决「sessionRef 双键取自终态条」,注册条目不是锚源)。
|
|
96
|
+
*/
|
|
97
|
+
function v2RegisteredLine(entryId: string, saId: string): string {
|
|
98
|
+
return JSON.stringify({
|
|
99
|
+
type: 'custom',
|
|
100
|
+
customType: 'subagent-record',
|
|
101
|
+
id: entryId,
|
|
102
|
+
parentId: null,
|
|
103
|
+
data: {
|
|
104
|
+
v: 2,
|
|
105
|
+
kind: 'registered',
|
|
106
|
+
id: saId,
|
|
107
|
+
agent: 'dev',
|
|
108
|
+
task: 't',
|
|
109
|
+
slug: 's',
|
|
110
|
+
origin: 'tool',
|
|
111
|
+
rootSessionId: 'root-1',
|
|
112
|
+
depth: 0,
|
|
113
|
+
startedAt: 1,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 缺 dbPath 的 sessionRef(pi record 形态:只有 sessionId 单键)。 */
|
|
119
|
+
const REF_NO_DBPATH = { sessionId: 'sess-pi-x' }
|
|
120
|
+
|
|
121
|
+
/** 缺 sessionId 的 sessionRef(半键)。 */
|
|
122
|
+
const REF_NO_SESSION_ID = { dbPath: DB_PATH }
|
|
123
|
+
|
|
124
|
+
function writeMainSession(name: string, lines: string[]): string {
|
|
125
|
+
mkdirSync(dir, { recursive: true })
|
|
126
|
+
const file = join(dir, name)
|
|
127
|
+
writeFileSync(file, lines.join('\n') + '\n', 'utf8')
|
|
128
|
+
return file
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('变体 E:同 sa-id 多条 entry 取文件顺序末条(F16 覆写)', () => {
|
|
132
|
+
it('首条 sess-A / 末条 sess-B → 命中 sess-B 且 ≠ sess-A(取首条/合并必失败)', async () => {
|
|
133
|
+
const file = writeMainSession('main.jsonl', [
|
|
134
|
+
JSON.stringify({ type: 'session', id: 'main-1', cwd: '/tmp/proj' }),
|
|
135
|
+
messageLine('m1', 'dispatch subagent'),
|
|
136
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
137
|
+
messageLine('m2', 'round 1 done'),
|
|
138
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
142
|
+
|
|
143
|
+
// 非平凡断言:两个不同锚值使「末条 vs 首条」结果可区分
|
|
144
|
+
expect(anchor).toBeDefined()
|
|
145
|
+
expect(anchor?.sessionId).toBe('sess-B')
|
|
146
|
+
expect(anchor?.sessionId).not.toBe('sess-A')
|
|
147
|
+
expect(anchor?.dbPath).toBe(DB_PATH)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('多条不同 sa-id 交错:只命中目标 id 的末条,不串锚', async () => {
|
|
151
|
+
const file = writeMainSession('main.jsonl', [
|
|
152
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
153
|
+
recordLine('e1', 'sa-1', { sessionId: 'sa1-r1', dbPath: DB_PATH }),
|
|
154
|
+
recordLine('e2', 'sa-2', { sessionId: 'sa2-r1', dbPath: DB_PATH }),
|
|
155
|
+
recordLine('e3', 'sa-1', { sessionId: 'sa1-r2', dbPath: DB_PATH }),
|
|
156
|
+
recordLine('e4', 'sa-2', { sessionId: 'sa2-r2', dbPath: DB_PATH }),
|
|
157
|
+
recordLine('e5', 'sa-1', { sessionId: 'sa1-r3', dbPath: DB_PATH }),
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
expect((await findZcodeEntryAnchor([file], 'sa-1'))?.sessionId).toBe('sa1-r3')
|
|
161
|
+
expect((await findZcodeEntryAnchor([file], 'sa-2'))?.sessionId).toBe('sa2-r2')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('行级容错(best-effort,不因一行坏 JSON 失败整文件)', () => {
|
|
166
|
+
it('坏行穿插 + 末行半行:正常命中末条完整条目', async () => {
|
|
167
|
+
const file = writeMainSession('main.jsonl', [
|
|
168
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
169
|
+
'not-json-at-all{{{',
|
|
170
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
171
|
+
'{"type":"custom","customType":"subagent-record","data":{"broken"',
|
|
172
|
+
])
|
|
173
|
+
|
|
174
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
175
|
+
expect(anchor?.sessionId).toBe('sess-A')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('形状校验窄而严:缺键条目不算命中,继续找更早条目', () => {
|
|
180
|
+
it('末条 sessionRef 缺 dbPath → 命中更早的完整条目', async () => {
|
|
181
|
+
const file = writeMainSession('main.jsonl', [
|
|
182
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
183
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
|
|
184
|
+
recordLine('e2', 'sa-1', REF_NO_DBPATH),
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
188
|
+
expect(anchor?.sessionId).toBe('sess-EARLY')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('全部条目缺双键 → not-found(undefined)', async () => {
|
|
192
|
+
const file = writeMainSession('main.jsonl', [
|
|
193
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
194
|
+
recordLine('e1', 'sa-1', REF_NO_DBPATH),
|
|
195
|
+
recordLine('e2', 'sa-1', REF_NO_SESSION_ID),
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('sessionRef 键为空串 → 不算命中(非空 string 才算锚)', async () => {
|
|
202
|
+
const file = writeMainSession('main.jsonl', [
|
|
203
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
204
|
+
recordLine('e1', 'sa-1', { sessionId: '', dbPath: DB_PATH }),
|
|
205
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-A', dbPath: '' }),
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('data.v 为认识的 v1/v2 之外的版本(future-v,如 3)不算命中,更早 v=1 条目仍可命中', async () => {
|
|
212
|
+
const file = writeMainSession('main.jsonl', [
|
|
213
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
214
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
215
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-V3', dbPath: DB_PATH }, 3),
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
219
|
+
expect(anchor?.sessionId).toBe('sess-A')
|
|
220
|
+
expect(anchor?.sessionId).not.toBe('sess-V3')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ============================================================
|
|
225
|
+
// v2 终态条锚定(W1 D1 版本门补齐:sessionRef 双键取自终态条)
|
|
226
|
+
// ============================================================
|
|
227
|
+
|
|
228
|
+
describe('v2 终态条锚定:kind="settled" + engineHandle.sessionRef 双键', () => {
|
|
229
|
+
it('v2 终态条完整锚(sessionRef 双键齐)→ 命中', async () => {
|
|
230
|
+
const file = writeMainSession('main.jsonl', [
|
|
231
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
232
|
+
messageLine('m1', 'dispatch'),
|
|
233
|
+
v2SettledLine('e1', 'sa-1', {
|
|
234
|
+
engine: 'zcode',
|
|
235
|
+
sessionRef: { sessionId: 'sess-V2A', dbPath: DB_PATH },
|
|
236
|
+
}),
|
|
237
|
+
])
|
|
238
|
+
|
|
239
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
240
|
+
expect(anchor).toBeDefined()
|
|
241
|
+
expect(anchor?.sessionId).toBe('sess-V2A')
|
|
242
|
+
expect(anchor?.dbPath).toBe(DB_PATH)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('v2 注册条(kind="registered",不携带 engineHandle)→ 不命中(锚只取自终态条)', async () => {
|
|
246
|
+
const file = writeMainSession('main.jsonl', [
|
|
247
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
248
|
+
v2RegisteredLine('e-reg', 'sa-1'),
|
|
249
|
+
])
|
|
250
|
+
|
|
251
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('v1 快照条(早)+ v2 终态条(晚)→ 末条 v2 胜(末条锚定跨版本一致)', async () => {
|
|
255
|
+
const file = writeMainSession('main.jsonl', [
|
|
256
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
257
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-V1OLD', dbPath: DB_PATH }),
|
|
258
|
+
v2SettledLine('e2', 'sa-1', {
|
|
259
|
+
engine: 'zcode',
|
|
260
|
+
sessionRef: { sessionId: 'sess-V2NEW', dbPath: DB_PATH },
|
|
261
|
+
}),
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
265
|
+
expect(anchor?.sessionId).toBe('sess-V2NEW')
|
|
266
|
+
expect(anchor?.sessionId).not.toBe('sess-V1OLD')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('v2 终态条锚残缺(缺 engineHandle)→ 不命中,更早 v1 完整条目仍可命中(缺键不遮蔽)', async () => {
|
|
270
|
+
const file = writeMainSession('main.jsonl', [
|
|
271
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
272
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
|
|
273
|
+
v2SettledLine('e2', 'sa-1', { engine: 'zcode' }), // 无 sessionRef
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
const anchor = await findZcodeEntryAnchor([file], 'sa-1')
|
|
277
|
+
expect(anchor?.sessionId).toBe('sess-EARLY')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('v2 但 kind 不在词表(unknown-kind,非 registered/settled)→ 不命中', async () => {
|
|
281
|
+
const badKind = JSON.stringify({
|
|
282
|
+
type: 'custom',
|
|
283
|
+
customType: 'subagent-record',
|
|
284
|
+
id: 'e-bad',
|
|
285
|
+
parentId: null,
|
|
286
|
+
data: {
|
|
287
|
+
v: 2,
|
|
288
|
+
kind: 'migrated',
|
|
289
|
+
id: 'sa-1',
|
|
290
|
+
engineHandle: { sessionRef: { sessionId: 'sess-BAD', dbPath: DB_PATH }, poolKey: 'shared' },
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
const file = writeMainSession('main.jsonl', [
|
|
294
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
295
|
+
badKind,
|
|
296
|
+
])
|
|
297
|
+
|
|
298
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
describe('无关条目不命中', () => {
|
|
303
|
+
it('非 subagent-record 的 custom entry、id 不匹配的 record 均跳过', async () => {
|
|
304
|
+
const todoLine = JSON.stringify({
|
|
305
|
+
type: 'custom',
|
|
306
|
+
customType: 'todo',
|
|
307
|
+
id: 't1',
|
|
308
|
+
parentId: null,
|
|
309
|
+
data: { v: 1, id: 'sa-1', engineHandle: { sessionRef: { sessionId: 'sess-TODO', dbPath: DB_PATH } } },
|
|
310
|
+
})
|
|
311
|
+
const file = writeMainSession('main.jsonl', [
|
|
312
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
313
|
+
todoLine,
|
|
314
|
+
recordLine('e1', 'sa-other', { sessionId: 'sess-OTHER', dbPath: DB_PATH }),
|
|
315
|
+
])
|
|
316
|
+
|
|
317
|
+
expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
|
|
318
|
+
expect((await findZcodeEntryAnchor([file], 'sa-other'))?.sessionId).toBe('sess-OTHER')
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('候选文件列表:顺序回退 + 命中即止(越界语义由调用方承载)', () => {
|
|
323
|
+
it('前一候选未命中 → 后一候选命中', async () => {
|
|
324
|
+
const empty = writeMainSession('empty.jsonl', [JSON.stringify({ type: 'session', id: 's0' })])
|
|
325
|
+
const withAnchor = writeMainSession('with-anchor.jsonl', [
|
|
326
|
+
JSON.stringify({ type: 'session', id: 'main-2' }),
|
|
327
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
328
|
+
])
|
|
329
|
+
|
|
330
|
+
const anchor: ZcodeAnchor | undefined = await findZcodeEntryAnchor([empty, withAnchor], 'sa-1')
|
|
331
|
+
expect(anchor?.sessionId).toBe('sess-B')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('前一候选命中即止:不跨文件合并(后一候选的更新条目不覆盖)', async () => {
|
|
335
|
+
const first = writeMainSession('first.jsonl', [
|
|
336
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
337
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
|
|
338
|
+
])
|
|
339
|
+
const second = writeMainSession('second.jsonl', [
|
|
340
|
+
JSON.stringify({ type: 'session', id: 'main-2' }),
|
|
341
|
+
recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
342
|
+
])
|
|
343
|
+
|
|
344
|
+
const anchor = await findZcodeEntryAnchor([first, second], 'sa-1')
|
|
345
|
+
expect(anchor?.sessionId).toBe('sess-A')
|
|
346
|
+
expect(anchor?.sessionId).not.toBe('sess-B')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('候选不可读(不存在)→ 跳过继续,全不可读 → not-found', async () => {
|
|
350
|
+
const withAnchor = writeMainSession('with-anchor.jsonl', [
|
|
351
|
+
JSON.stringify({ type: 'session', id: 'main-1' }),
|
|
352
|
+
recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
|
|
353
|
+
])
|
|
354
|
+
|
|
355
|
+
expect((await findZcodeEntryAnchor([join(dir, 'missing.jsonl'), withAnchor], 'sa-1'))?.sessionId).toBe('sess-B')
|
|
356
|
+
expect(await findZcodeEntryAnchor([join(dir, 'missing.jsonl')], 'sa-1')).toBeUndefined()
|
|
357
|
+
expect(await findZcodeEntryAnchor([], 'sa-1')).toBeUndefined()
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
describe('协议字面量漂移守卫(跨包契约)', () => {
|
|
362
|
+
it('entry-anchor 本地 customType 字面量 === subagent-core 写侧单源', () => {
|
|
363
|
+
// 源码本地持有磁盘协议字符串(生产依赖面不引 subagent-core),此处锚定两侧同值
|
|
364
|
+
expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('entry-anchor 本地 v2 认识版本 === subagent-core 写侧单源(写侧再升版本本守卫先红)', () => {
|
|
368
|
+
expect(SUBAGENT_RECORD_ENTRY_VERSION).toBe(2)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
@@ -17,11 +17,9 @@ import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } f
|
|
|
17
17
|
* - TC-m3b-cycle-detection:workflow 指针环(A→B→A),visited Set 防环
|
|
18
18
|
* - TC-m3b-single-node:单节点树(无后代,ES5)
|
|
19
19
|
* - TC-m3b-source-priority:parentRecordId 三级数据源优先级(manifest>identity>flat,DM4)
|
|
20
|
-
* - TC-m3b-real-data-guard:真实形态数据守卫(合成 fixture
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* 新旧机制混合 / 孤儿 manifest / 坏 manifest / 外来家族),guard 语义不变——解析不抛错 /
|
|
24
|
-
* 树非空 / 结构自洽,不锁 sourceMode 具体值
|
|
20
|
+
* - TC-m3b-real-data-guard:真实形态数据守卫(合成 fixture):mkdtemp 临时目录仿真真实
|
|
21
|
+
* agentDir 布局(时间戳前缀文件名 / 新旧机制混合 / 孤儿 manifest / 坏 manifest / 外来
|
|
22
|
+
* 家族),guard 语义断言——解析不抛错 / 树非空 / 结构自洽,不锁 sourceMode 具体值
|
|
25
23
|
*/
|
|
26
24
|
|
|
27
25
|
// ---- fixture 常量(uuid 特征,互不为子串,满足 extractSessionIdFromFilename)----
|
|
@@ -596,6 +594,12 @@ describe('buildExecutionTree - fixture', () => {
|
|
|
596
594
|
const tree2 = await buildExecutionTree(MAIN, dir)
|
|
597
595
|
expect(tree2.root.sessionFile).toBeUndefined()
|
|
598
596
|
expect(tree2.totalNodes).toBe(1)
|
|
597
|
+
|
|
598
|
+
// 空串哨兵归一:mainSessionFile=''(zcode family 路由反查未命中的降级形态)与
|
|
599
|
+
// 不传等价——root 节点 details 不携带 sessionFile:"" 这类非路径占位值
|
|
600
|
+
const tree3 = await buildExecutionTree(MAIN, dir, '')
|
|
601
|
+
expect(tree3.root.sessionFile).toBeUndefined()
|
|
602
|
+
expect(tree3.totalNodes).toBe(1)
|
|
599
603
|
})
|
|
600
604
|
|
|
601
605
|
it('TC-m3b-max-depth:21+ 层 parentRecordId 链在 MAX_DEPTH(20) 截断,truncated=true 不抛错(MF-4)', async () => {
|
|
@@ -679,13 +683,9 @@ describe('buildExecutionTree - fixture', () => {
|
|
|
679
683
|
})
|
|
680
684
|
|
|
681
685
|
// ============================================================
|
|
682
|
-
// 真实形态数据守卫(合成 fixture
|
|
686
|
+
// 真实形态数据守卫(合成 fixture):mkdtemp 临时目录仿真真实 agentDir 布局,
|
|
687
|
+
// guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域),不触碰真实数据目录。
|
|
683
688
|
// ============================================================
|
|
684
|
-
//
|
|
685
|
-
// 原用例直读本机 ~/.pi/agent(execSync 探测 + findRealTreeRoot 扫真实 records 动态发现
|
|
686
|
-
// 家族根,skipIf CI 无数据):真实数据演化后确定性红(impl-plan 台账登记根因),且触碰
|
|
687
|
-
// 真实数据目录违反 TEST-STRATEGY 红线。数据面合成化——mkdtemp 临时目录仿真真实 agentDir
|
|
688
|
-
// 布局,guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域)原样保留。
|
|
689
689
|
|
|
690
690
|
describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
|
|
691
691
|
let dir: string
|
|
@@ -698,7 +698,7 @@ describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
|
|
|
698
698
|
})
|
|
699
699
|
|
|
700
700
|
it('TC-m3b-real-data-guard:真实形态布局(时间戳文件名/新旧机制混合/孤儿 manifest/坏 manifest/外来家族)解析不抛错,树非空且结构自洽', async () => {
|
|
701
|
-
// 仿真真实 agentDir
|
|
701
|
+
// 仿真真实 agentDir 数据形态(全部 mkdtemp 自建自删,确定性可重跑):
|
|
702
702
|
// - session 文件名带时间戳前缀(<ISO 时间戳>_<sessionId>.jsonl,真实 pi 落盘形态)
|
|
703
703
|
// - 混合新旧机制:A 顶层(无 parentRecordId)→ B 嵌套(manifest.parentRecordId 精确链);
|
|
704
704
|
// C 旧机制顶层(manifest/identity 均无 parentRecordId → flat 挂 main)
|