@zhushanwen/pi-session-reader 0.7.0 → 0.8.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -17,14 +17,14 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "@zhushanwen/pi-extension-logger": "0.6.1",
20
- "@zhushanwen/pi-ext-guards": "0.4.1",
20
+ "@zhushanwen/pi-ext-guards": "0.4.2",
21
21
  "@zhushanwen/session-core": "0.11.0",
22
- "@zhushanwen/zcode-session-source": "0.2.0"
22
+ "@zhushanwen/zcode-session-source": "0.2.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@vitest/coverage-v8": "^4.1.9",
26
26
  "vitest": "^4.1.8",
27
- "@zhushanwen/subagent-core": "0.11.1"
27
+ "@zhushanwen/subagent-core": "0.13.0"
28
28
  },
29
29
  "files": [
30
30
  "index.ts",
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
 
6
- import { SUBAGENT_RECORD_CUSTOM_TYPE } from '@zhushanwen/subagent-core'
6
+ import { SUBAGENT_RECORD_CUSTOM_TYPE, SUBAGENT_RECORD_ENTRY_VERSION } from '@zhushanwen/subagent-core'
7
7
 
8
8
  import { findZcodeEntryAnchor, type ZcodeAnchor } from '../discovery/entry-anchor.js'
9
9
 
@@ -53,6 +53,68 @@ function messageLine(entryId: string, text: string): string {
53
53
  })
54
54
  }
55
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
+
56
118
  /** 缺 dbPath 的 sessionRef(pi record 形态:只有 sessionId 单键)。 */
57
119
  const REF_NO_DBPATH = { sessionId: 'sess-pi-x' }
58
120
 
@@ -146,16 +208,94 @@ describe('形状校验窄而严:缺键条目不算命中,继续找更早条
146
208
  expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
147
209
  })
148
210
 
149
- it('data.v ≠ 1 的条目不算命中,更早 v=1 条目仍可命中', async () => {
211
+ it('data.v 为认识的 v1/v2 之外的版本(future-v,如 3)不算命中,更早 v=1 条目仍可命中', async () => {
150
212
  const file = writeMainSession('main.jsonl', [
151
213
  JSON.stringify({ type: 'session', id: 'main-1' }),
152
214
  recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
153
- recordLine('e2', 'sa-1', { sessionId: 'sess-V2', dbPath: DB_PATH }, 2),
215
+ recordLine('e2', 'sa-1', { sessionId: 'sess-V3', dbPath: DB_PATH }, 3),
154
216
  ])
155
217
 
156
218
  const anchor = await findZcodeEntryAnchor([file], 'sa-1')
157
219
  expect(anchor?.sessionId).toBe('sess-A')
158
- expect(anchor?.sessionId).not.toBe('sess-V2')
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()
159
299
  })
160
300
  })
161
301
 
@@ -223,4 +363,8 @@ describe('协议字面量漂移守卫(跨包契约)', () => {
223
363
  // 源码本地持有磁盘协议字符串(生产依赖面不引 subagent-core),此处锚定两侧同值
224
364
  expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
225
365
  })
366
+
367
+ it('entry-anchor 本地 v2 认识版本 === subagent-core 写侧单源(写侧再升版本本守卫先红)', () => {
368
+ expect(SUBAGENT_RECORD_ENTRY_VERSION).toBe(2)
369
+ })
226
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)。原直读本机 ~/.pi/agent 动态
21
- * 发现家族根(skipIf CI 无数据),真实数据演化后确定性红且触碰真实数据目录(TEST-STRATEGY
22
- * 红线),2026-09 数据面合成化:mkdtemp 临时目录仿真真实 agentDir 布局(时间戳前缀文件名 /
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)----
@@ -685,13 +683,9 @@ describe('buildExecutionTree - fixture', () => {
685
683
  })
686
684
 
687
685
  // ============================================================
688
- // 真实形态数据守卫(合成 fixture,2026-09 测试债修复)
686
+ // 真实形态数据守卫(合成 fixture):mkdtemp 临时目录仿真真实 agentDir 布局,
687
+ // guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域),不触碰真实数据目录。
689
688
  // ============================================================
690
- //
691
- // 原用例直读本机 ~/.pi/agent(execSync 探测 + findRealTreeRoot 扫真实 records 动态发现
692
- // 家族根,skipIf CI 无数据):真实数据演化后确定性红(impl-plan 台账登记根因),且触碰
693
- // 真实数据目录违反 TEST-STRATEGY 红线。数据面合成化——mkdtemp 临时目录仿真真实 agentDir
694
- // 布局,guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域)原样保留。
695
689
 
696
690
  describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
697
691
  let dir: string
@@ -704,7 +698,7 @@ describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
704
698
  })
705
699
 
706
700
  it('TC-m3b-real-data-guard:真实形态布局(时间戳文件名/新旧机制混合/孤儿 manifest/坏 manifest/外来家族)解析不抛错,树非空且结构自洽', async () => {
707
- // 仿真真实 agentDir 数据形态(原直读本机 ~/.pi/agent,合成化后确定性可重跑):
701
+ // 仿真真实 agentDir 数据形态(全部 mkdtemp 自建自删,确定性可重跑):
708
702
  // - session 文件名带时间戳前缀(<ISO 时间戳>_<sessionId>.jsonl,真实 pi 落盘形态)
709
703
  // - 混合新旧机制:A 顶层(无 parentRecordId)→ B 嵌套(manifest.parentRecordId 精确链);
710
704
  // C 旧机制顶层(manifest/identity 均无 parentRecordId → flat 挂 main)
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { findSessions } from '../discovery/find.js'
6
- import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
6
+ import { FIX_E6 } from './fixtures.js'
7
7
 
8
8
  /**
9
9
  * 建一个假 session 文件:首行 header(type=session,含 id/cwd/parentSession),
@@ -534,61 +534,21 @@ describe('findSessions', () => {
534
534
  // B 不入选(uuid 命中短路,不走 task 匹配)
535
535
  expect(matches.some((m) => m.sessionId === idB)).toBe(false)
536
536
  })
537
-
538
- it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
539
- 'TC-u5-real-data-guard:find codex source:subagent 命中(本机有 codex 相关 subagent task)',
540
- async () => {
541
- const { matches, truncated } = await findSessions('codex', REAL_AGENT_DIR, {
542
- source: 'subagent',
543
- limit: 50,
544
- })
545
- expect(matches.length).toBeGreaterThan(0)
546
- expect(matches.every((m) => m.source === 'subagent')).toBe(true)
547
- expect(typeof truncated).toBe('boolean')
548
- },
549
- REAL_DATA_TIMEOUT_MS,
550
- )
551
537
  })
552
538
 
553
- it.skipIf(!HAS_E6)('真实数据:e6c96 匹配 019e6c96 开头的 session', async () => {
554
- const { matches } = await findSessions('e6c96', REAL_AGENT_DIR)
539
+ it('uuid 片段命中候选补全元数据:fileName/mtime/sizeBytes 真实值(019e6c96 锚)', async () => {
540
+ // 2 条 session、恰 1 条 id 含片段 'e6c96':uuid 片段路径命中后仍补读元数据字段。
541
+ // 命中文件按真实 pi 落盘命名(<id>.jsonl),fileName 断言含该 id
542
+ await makeSession(slugDir, { name: `${FIX_E6}.jsonl`, id: FIX_E6, cwd: '/demo' })
543
+ await makeSession(slugDir, { name: 'other.jsonl', id: '019ffff-cccc-dddd', cwd: '/demo' })
544
+
545
+ const { matches } = await findSessions('e6c96', agentDir)
555
546
  expect(matches.length).toBeGreaterThan(0)
556
- expect(matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
557
- // 真实值校验
558
547
  const hit = matches.find((m) => m.sessionId.startsWith('019e6c96'))!
559
548
  expect(hit.fileName).toContain('019e6c96')
560
549
  expect(hit.mtime).toBeGreaterThan(0)
561
550
  expect(hit.sizeBytes).toBeGreaterThan(0)
562
- }, REAL_DATA_TIMEOUT_MS)
563
-
564
- it.skipIf(!HAS_E6)("真实数据:recent 返回最近 N 个,mtime 倒序,truncated=true", async () => {
565
- const { matches, truncated } = await findSessions('recent', REAL_AGENT_DIR, { limit: 5 })
566
- expect(matches.length).toBeGreaterThan(0)
567
- expect(matches.length).toBeLessThanOrEqual(5)
568
- // mtime 倒序
569
- for (let i = 1; i < matches.length; i++) {
570
- expect(matches[i - 1].mtime).toBeGreaterThanOrEqual(matches[i].mtime)
571
- }
572
- // 真实 session 文件远多于 5 → 截断
573
- expect(truncated).toBe(true)
574
- }, REAL_DATA_TIMEOUT_MS)
575
-
576
- it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
577
- "真实数据:source:'subagent' 能找到 completed subagent(§7 场景 1 find 部分)",
578
- async () => {
579
- const { matches } = await findSessions('recent', REAL_AGENT_DIR, {
580
- source: 'subagent',
581
- limit: 5,
582
- })
583
- expect(matches.length).toBeGreaterThan(0)
584
- // 全部 source==='subagent',fileName 在 subagents/ 目录下
585
- for (const m of matches) {
586
- expect(m.source).toBe('subagent')
587
- expect(m.fileName).toContain('subagents')
588
- }
589
- },
590
- REAL_DATA_TIMEOUT_MS,
591
- )
551
+ })
592
552
  })
593
553
 
594
554
  // ============================================================
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 测试 fixture 常量(跨测试文件共享的 uuid 形态标识符)。
3
+ *
4
+ * 全部用例的项目内数据 = 各测试文件用 mkdtemp 自建自删的最小 session 文件
5
+ *(按测试断言逻辑构造,不读本机真实用户数据目录)。本文件只登记 uuid 常量,
6
+ * 取值约束:
7
+ * - 合法 uuid 形态(满足 extractSessionIdFromFilename / header id 谓词);
8
+ * - FIX_E6 含十六进制片段 'e6c96',作 uuid 片段匹配路径(find/resolveSessionId)的锚 id,
9
+ * 且完整 id 以 '019e6c96' 开头,供前缀断言。
10
+ *
11
+ * 本文件无 .test.ts 后缀,vitest include 只收集 `src/__tests__/` 下以 .test.ts 结尾的文件,不收集本文件。
12
+ */
13
+
14
+ /** 主 fixture session id:片段 'e6c96' 命中 + 完整 id '019e6c96' 前缀断言的锚。 */
15
+ export const FIX_E6 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
2
5
  import { parseSessionContent, parseSessionFile, type Entry } from '@zhushanwen/session-core'
3
- import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
4
6
 
5
7
  /** 构造单行 JSONL entry 字符串 */
6
8
  function line(obj: Record<string, unknown>): string {
@@ -157,14 +159,37 @@ describe('parseSessionContent', () => {
157
159
  })
158
160
 
159
161
  describe('parseSessionFile', () => {
160
- it.skipIf(!HAS_REAL_SESSION)('真实 session 019e6c96:1204 entries / 0 skipped / 非 partial', async () => {
161
- const result = await parseSessionFile(REAL_SESSION)
162
-
163
- expect(result.entries).toHaveLength(1204)
164
- expect(result.skippedLines).toBe(0)
165
- expect(result.lastLinePartial).toBe(false)
166
- // 5.4MB 量级
167
- expect(result.totalBytes).toBeGreaterThan(5_000_000)
168
- // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
169
- }, 60000)
162
+ it('磁盘文件全量解析:entries 数 = 行数 / 0 skipped / 非 partial / totalBytes 与内容一致', async () => {
163
+ // mkdtemp 自建自删的 fixture 文件(不读本机真实数据目录):session header +
164
+ // 10 条链式 message 的最小行集,验证「文件读取 → 逐行解析」回路的完整性与计数字段
165
+ const dir = await mkdtemp(join(tmpdir(), 'parser-file-'))
166
+ try {
167
+ const lines = [
168
+ line({ type: 'session', id: 's1', parentId: null, cwd: '/x' }),
169
+ ...Array.from({ length: 10 }, (_, i) =>
170
+ line({
171
+ type: 'message',
172
+ id: `m${i}`,
173
+ parentId: i === 0 ? 's1' : `m${i - 1}`,
174
+ message: {
175
+ role: i % 2 === 0 ? 'user' : 'assistant',
176
+ content: [{ type: 'text', text: `text ${i}` }],
177
+ },
178
+ }),
179
+ ),
180
+ ]
181
+ const content = lines.join('\n') + '\n'
182
+ const path = join(dir, 'fixture-session.jsonl')
183
+ await writeFile(path, content)
184
+
185
+ const result = await parseSessionFile(path)
186
+
187
+ expect(result.entries).toHaveLength(11)
188
+ expect(result.skippedLines).toBe(0)
189
+ expect(result.lastLinePartial).toBe(false)
190
+ expect(result.totalBytes).toBe(Buffer.byteLength(content, 'utf8'))
191
+ } finally {
192
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
193
+ }
194
+ })
170
195
  })
@@ -1,11 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { renderOutline, renderExpand, renderDetail, type ToolResultSummaryEntry } from '../core/render.js'
3
- import { parseSessionFile, type Entry } from '@zhushanwen/session-core'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
  import type { Turn } from '../core/turns.js'
5
5
  import type { TreeView } from '../core/tree.js'
6
- import { buildTreeView } from '../core/tree.js'
7
- import { segmentTurns } from '../core/turns.js'
8
- import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
9
6
 
10
7
  // ---- 构造助手 ----
11
8
 
@@ -203,25 +200,32 @@ describe('renderOutline', () => {
203
200
  expect(noBranch.lines).toEqual(['T000 · hi'])
204
201
  })
205
202
 
206
- it.skipIf(!HAS_REAL_SESSION)('8. 真实 019e6c96:outline tokenEstimate <= 1500 + assistantBrief/toolSummary 非空(v2 O1)', async () => {
207
- // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 变长,阈值 600→1500(design §3.3 D4)
208
- const parsed = await parseSessionFile(REAL_SESSION)
209
- const tree = buildTreeView(parsed.entries)
210
- const turns = segmentTurns(parsed.entries, new Set(tree.leafPath))
211
- const result = renderOutline(turns, tree, { budget: 2000 })
203
+ it('8. 多 turn outline:tokenEstimate <= 1500 + assistantBrief/toolSummary 非空 + stats 完整(v2 O1)', () => {
204
+ // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 行含结论与工具聚合,
205
+ // 阈值 1500(design §3.3 D4)。3 turn 合成数据,每 turn 带 toolCall + toolResult
206
+ const turns = [0, 1, 2].map((i) =>
207
+ turn(
208
+ i,
209
+ [
210
+ uEntry(`U${i}`, `问题 ${i}`),
211
+ aEntry(`A${i}`, `结论 ${i}`, [{ name: 'bash' }, { name: 'read' }]),
212
+ tEntry(`T${i}`, 'output'),
213
+ ],
214
+ { userEntry: uEntry(`U${i}`, `问题 ${i}`) },
215
+ ),
216
+ )
217
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
212
218
 
213
- expect(result.turns.length).toBe(32)
219
+ expect(result.turns.length).toBe(3)
214
220
  expect(result.tokenEstimate).toBeLessThanOrEqual(1500)
215
- // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示真实工具(修 v1 恒空 bug)
221
+ // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示工具(修 v1 恒空 bug)
216
222
  expect(result.turns.some((b) => b.assistantBrief !== '')).toBe(true)
217
223
  expect(result.turns.some((b) => b.toolSummary !== '')).toBe(true)
218
224
  // stats 完整性
219
- expect(result.stats.totalTurns).toBe(32)
220
- // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
221
- // 工具层仅覆盖 stats.totalBytes 与 skippedLines(ParseResult 无 totalEntries 字段)。M1 验量级。
222
- expect(result.stats.totalEntries).toBeGreaterThan(1000)
223
- // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
224
- }, 60000)
225
+ expect(result.stats.totalTurns).toBe(3)
226
+ // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过)
227
+ expect(result.stats.totalEntries).toBeGreaterThan(0)
228
+ })
225
229
  })
226
230
 
227
231
  describe('renderExpand', () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
- import { mkdtemp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
4
  import { join, dirname } from 'node:path'
5
5
  import {
6
6
  resolveSessionRoots,
@@ -9,7 +9,7 @@ import {
9
9
  type SessionRootSignals,
10
10
  } from '../discovery/roots.js'
11
11
  import { buildFamilyFromFs } from '../discovery/subagents.js'
12
- import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_AGENT_DIR, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
12
+ import { FIX_E6 } from './fixtures.js'
13
13
 
14
14
  // ============================================================
15
15
  // A3(ext-simplify-04 U4):listMainSessions/listSubagentSessions 薄包装已删除,
@@ -35,7 +35,7 @@ async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n
35
35
  await writeFile(path, content)
36
36
  }
37
37
 
38
- /** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾,实证见 real-data.ts) */
38
+ /** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾) */
39
39
  const SLUG = '--Users-foo--'
40
40
 
41
41
  describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆盖)', () => {
@@ -100,8 +100,18 @@ describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆
100
100
  await expect(mainSessionFiles(join(dir, 'no-such-dir'))).resolves.toEqual([])
101
101
  })
102
102
 
103
- it.skipIf(!HAS_E6)('真实数据:扫描 ~/.pi/agent,含 019e6c96,不含 .finalized 与 wf-', async () => {
104
- const result = await mainSessionFiles(REAL_AGENT_DIR)
103
+ it('扫描 sessions/:含目标 session,排除 .finalized 与 workflow-state 目录(019e6c96 锚)', async () => {
104
+ // wf-state 文件(wf-*.jsonl)经 workflow-state 目录跳过排除——main 根无独立的
105
+ // wf- 文件名前缀过滤,slug 顶层放 wf-*.jsonl 会被收进候选集(该形态不在真实
106
+ // 落盘布局中出现:wf-state 恒在 workflow-state/ 目录内)
107
+ const slug = '--Users-foo--'
108
+ const slugDir = join(dir, 'sessions', slug)
109
+ await mkdir(join(slugDir, 'workflow-state'), { recursive: true })
110
+ await writeFile(join(slugDir, `${FIX_E6}.jsonl`), `{"type":"session","id":"${FIX_E6}"}\n`)
111
+ await writeFile(join(slugDir, 'b.jsonl.finalized'), '{"type":"session","id":"b"}\n')
112
+ await writeFile(join(slugDir, 'workflow-state', 'wf-inner.jsonl'), '{"v":"wf-run-v1"}\n')
113
+
114
+ const result = await mainSessionFiles(dir)
105
115
  expect(result.length).toBeGreaterThan(0)
106
116
  // 含目标 session
107
117
  expect(result.some((m) => m.path.includes('019e6c96'))).toBe(true)
@@ -109,11 +119,9 @@ describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆
109
119
  expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
110
120
  // 排除 workflow-state 目录
111
121
  expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
112
- // 排除 wf- 前缀文件名
113
- expect(result.every((m) => !m.path.split('/').pop()!.startsWith('wf-'))).toBe(true)
114
122
  // mtime/size 真实
115
123
  expect(result.every((m) => m.mtime > 0 && m.size > 0)).toBe(true)
116
- }, REAL_DATA_TIMEOUT_MS)
124
+ })
117
125
  })
118
126
 
119
127
  describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后保覆盖)', () => {
@@ -156,12 +164,6 @@ describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后
156
164
  it('无 subagents 目录返回 [],不抛错', async () => {
157
165
  await expect(subagentSessionFiles(dir)).resolves.toEqual([])
158
166
  })
159
-
160
- it.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:扫描 ~/.pi/agent/subagents 返回非空', async () => {
161
- const result = await subagentSessionFiles(REAL_AGENT_DIR)
162
- expect(result.length).toBeGreaterThan(0)
163
- expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
164
- }, REAL_DATA_TIMEOUT_MS)
165
167
  })
166
168
 
167
169
  // ============================================================
@@ -320,26 +322,22 @@ describe('resolveSessionRoots', () => {
320
322
  await expect(resolveSessionRoots({ agentDir: '' })).resolves.toEqual([])
321
323
  })
322
324
 
323
- it.skipIf(!HAS_REAL_AGENT_DIR)(
324
- '§11.2 真实路径集合回归:encodeCwd 判据在真实 sessions 子目录上自洽,剥层分支被真实数据覆盖',
325
- async () => {
326
- const sessionsDir = join(REAL_AGENT_DIR, 'sessions')
327
- const entries = await readdir(sessionsDir, { withFileTypes: true })
328
- const dirs = entries.filter((e) => e.isDirectory())
329
- expect(dirs.length).toBeGreaterThan(0)
330
- let encodeCwdCount = 0
331
- for (const d of dirs) {
332
- const asLive = join(sessionsDir, d.name)
333
- const matchesShape = d.name.startsWith('--') && d.name.endsWith('--')
334
- // 判据自洽:匹配形态 → 剥层到父目录;不匹配(如 permission-forwarding/)→ 取自身
335
- expect(normalizeLiveSessionDir(asLive)).toBe(matchesShape ? sessionsDir : asLive)
336
- if (matchesShape) encodeCwdCount++
337
- }
338
- // 真实集合以 encodeCwd 目录为主体——剥层分支确实被真实数据覆盖
339
- expect(encodeCwdCount).toBeGreaterThan(0)
340
- },
341
- 30000,
342
- )
325
+ it('encodeCwd 判据自洽:--xx-- 形态子目录剥层到 sessions 根,普通名子目录取自身', async () => {
326
+ // 两种目录形态各造一个:encodeCwd 编码目录(剥层分支)与普通目录名(取自身分支)
327
+ const tmp = await mkdtemp(join(tmpdir(), 'roots-encode-'))
328
+ try {
329
+ const sessionsDir = join(tmp, 'agent', 'sessions')
330
+ const encoded = join(sessionsDir, '--Users-foo--')
331
+ const plain = join(sessionsDir, 'permission-forwarding')
332
+ await writeJsonl(join(encoded, 'a.jsonl'))
333
+ await writeJsonl(join(plain, 'p.jsonl'))
334
+ // 判据自洽:匹配 encodeCwd 形态 → 剥层到父目录;不匹配(如 permission-forwarding/)→ 取自身
335
+ expect(normalizeLiveSessionDir(encoded)).toBe(sessionsDir)
336
+ expect(normalizeLiveSessionDir(plain)).toBe(plain)
337
+ } finally {
338
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
339
+ }
340
+ })
343
341
  })
344
342
 
345
343
  describe('buildFamilyFromFs not-found 文案(U1 并入:列实际扫描候选根)', () => {