@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.
Files changed (43) hide show
  1. package/README.md +100 -0
  2. package/package.json +6 -3
  3. package/src/__tests__/entry-anchor.test.ts +370 -0
  4. package/src/__tests__/execution-tree.test.ts +12 -12
  5. package/src/__tests__/family-zcode.test.ts +255 -0
  6. package/src/__tests__/family.test.ts +1 -1
  7. package/src/__tests__/find.test.ts +32 -49
  8. package/src/__tests__/fixtures.ts +15 -0
  9. package/src/__tests__/parser.test.ts +37 -13
  10. package/src/__tests__/render.test.ts +22 -19
  11. package/src/__tests__/result.test.ts +2 -2
  12. package/src/__tests__/roots.test.ts +32 -34
  13. package/src/__tests__/subagents.test.ts +131 -108
  14. package/src/__tests__/tool-handler.test.ts +506 -153
  15. package/src/__tests__/toolcall.test.ts +1 -1
  16. package/src/__tests__/tree.test.ts +1 -1
  17. package/src/__tests__/turns.test.ts +21 -20
  18. package/src/__tests__/workflow.test.ts +319 -23
  19. package/src/__tests__/zcode-manifest.test.ts +361 -0
  20. package/src/__tests__/zcode-routing.test.ts +751 -0
  21. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  22. package/src/core/execution-tree.ts +3 -1
  23. package/src/core/family.ts +80 -1
  24. package/src/core/render.ts +1 -1
  25. package/src/core/toolcall.ts +2 -2
  26. package/src/core/tree.ts +1 -1
  27. package/src/core/turns.ts +1 -1
  28. package/src/core/workflow.ts +196 -6
  29. package/src/discovery/entry-anchor.ts +139 -0
  30. package/src/discovery/roots.ts +1 -2
  31. package/src/discovery/session-header.ts +30 -69
  32. package/src/discovery/subagents.ts +9 -2
  33. package/src/discovery/whitelist.ts +87 -0
  34. package/src/discovery/workflows.ts +232 -22
  35. package/src/discovery/zcode-manifest.ts +277 -0
  36. package/src/index.ts +7 -0
  37. package/src/result-action.ts +14 -5
  38. package/src/search-across.ts +1 -1
  39. package/src/tool-format.ts +455 -0
  40. package/src/tool-handler.ts +400 -344
  41. package/src/zcode-anchor-classify.ts +74 -0
  42. package/src/__tests__/real-data.ts +0 -96
  43. package/src/core/parser.ts +0 -167
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { DatabaseSync } from 'node:sqlite'
6
+
7
+ import { buildFamilyIndex, resolveFamily, buildZcodeSubagentsByRoot } from '../core/family.js'
8
+ import type { Entry, ZcodeFamilyNode } from '../core/family.js'
9
+ import { listZcodeManifests } from '../discovery/zcode-manifest.js'
10
+
11
+ // ============================================================
12
+ // family zcode 分支(design session-reader-shared-core §3.3 D5-1 / §4 场景 2)。
13
+ //
14
+ // 判据(两项、不开库):锚可解析(枚举谓词已过)∧ 库文件存在(stat)→ cleanedUp=false;
15
+ // 缺一 → true(GC 语义,不报错不抛异常)。刻意不含「库内 session 查询」第三项——
16
+ // 静息态 CANTOPEN 会误判活会话 + 列表视图不开库;反向断言(库文件在但库内 session
17
+ // 行被删 → 仍 false)即「列表视图不开库」的分工声明断言。
18
+ //
19
+ // 与 pi 判据不同构(D5-1 显式声明):pi = fileStats JSONL 存在性;zcode 节点无
20
+ // JSONL 文件,按「库可达」判。pi 侧既有 fixture 行为零变化由 family.test.ts 全绿守卫
21
+ // (本文件零改动 family.test.ts——更强的零涟漪证据)。
22
+ // ============================================================
23
+
24
+ const ROOT = 'root-session-1'
25
+
26
+ let dir: string
27
+
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'family-zcode-test-'))
30
+ })
31
+ afterEach(() => {
32
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
33
+ })
34
+
35
+ function header(id: string): Entry {
36
+ return { type: 'session', id, parentId: null, cwd: '/proj' }
37
+ }
38
+
39
+ /** zcode 节点(core 层判据输入,与 ZcodeManifestRecord 结构兼容) */
40
+ function zcodeNode(opts: Partial<ZcodeFamilyNode> & { id: string }): ZcodeFamilyNode {
41
+ return {
42
+ rootSessionId: ROOT,
43
+ slug: 'worker',
44
+ dbPath: join(dir, 'db.sqlite'),
45
+ dbFileExists: false,
46
+ ...opts,
47
+ }
48
+ }
49
+
50
+ /** 在 <agentDir>/subagents/<slug>/records/ 下写 zcode manifest(F21 磁盘真实形态:无 sessionFile 键) */
51
+ function writeZcodeManifest(
52
+ agentDir: string,
53
+ saId: string,
54
+ sessionRef: Record<string, string>,
55
+ ): void {
56
+ const recordsDir = join(agentDir, 'subagents', 'proj-a', 'records')
57
+ mkdirSync(recordsDir, { recursive: true })
58
+ writeFileSync(
59
+ join(recordsDir, `${saId}.json`),
60
+ JSON.stringify({
61
+ id: saId,
62
+ rootSessionId: ROOT,
63
+ agentName: 'worker',
64
+ status: 'running',
65
+ engine: 'zcode',
66
+ engineHandle: { sessionRef, poolKey: 'shared' },
67
+ }),
68
+ 'utf8',
69
+ )
70
+ }
71
+
72
+ /** 造一个真实 sqlite 库(session/message/part 三级表先例形态)并插入 sess 行 */
73
+ function createFixtureDb(dbPath: string, sessionId: string): void {
74
+ const db = new DatabaseSync(dbPath)
75
+ try {
76
+ db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT)')
77
+ db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, sequence INTEGER)')
78
+ db.exec('CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT)')
79
+ db.prepare('INSERT INTO session (id, title) VALUES (?, ?)').run(sessionId, 'fixture')
80
+ } finally {
81
+ db.close()
82
+ }
83
+ }
84
+
85
+ describe('core:buildZcodeSubagentsByRoot / buildFamilyIndex 的 zcode 分支(D5-1 两态)', () => {
86
+ it('库文件存在 → cleanedUp=false;库文件缺席 → cleanedUp=true(缺一即 true,不抛错)', () => {
87
+ const index = buildFamilyIndex([header(ROOT)], [], new Map(), [
88
+ zcodeNode({ id: 'sa-alive', dbFileExists: true }),
89
+ zcodeNode({ id: 'sa-gone', dbFileExists: false }),
90
+ ])
91
+ const family = resolveFamily(ROOT, index)
92
+
93
+ const alive = family.subagents.find((s) => s.sessionId === 'sa-alive')
94
+ const gone = family.subagents.find((s) => s.sessionId === 'sa-gone')
95
+ expect(alive?.cleanedUp).toBe(false)
96
+ expect(gone?.cleanedUp).toBe(true)
97
+ })
98
+
99
+ it('zcode 节点按 rootSessionId 挂载,隔代场景与 pi 同链合并(resolveFamily 消费同一 subagentsByRoot)', () => {
100
+ const FORK = 'fork-session-1'
101
+ const headers = [header(ROOT), { ...header(FORK), parentSession: `/s/_${ROOT}.jsonl` }]
102
+ const index = buildFamilyIndex(headers, [], new Map(), [
103
+ zcodeNode({ id: 'sa-deep', rootSessionId: FORK }),
104
+ ])
105
+ // Q1 隔代:zcode subagent 挂在 fork 子代下,从 ROOT resolve 仍可见
106
+ expect(resolveFamily(ROOT, index).subagents.map((s) => s.sessionId)).toContain('sa-deep')
107
+ })
108
+
109
+ it('不传 zcodeNodes / 传空数组 → pi 行为不变(零涟漪等价)', () => {
110
+ const piIdent: Entry = {
111
+ type: 'custom',
112
+ id: 'sa-pi',
113
+ parentId: null,
114
+ customType: 'subagent-identity',
115
+ data: { rootSessionId: ROOT, slug: 'pi-sub' },
116
+ }
117
+ const stats = new Map([[ROOT, { mtime: 1, size: 2 }], ['sa-pi', { mtime: 3, size: 4 }]])
118
+ const without = buildFamilyIndex([header(ROOT)], [piIdent], stats)
119
+ const withEmpty = buildFamilyIndex([header(ROOT)], [piIdent], stats, [])
120
+ const a = resolveFamily(ROOT, without)
121
+ const b = resolveFamily(ROOT, withEmpty)
122
+ expect(b.subagents).toEqual(a.subagents)
123
+ expect(b.subagents[0]?.cleanedUp).toBe(false) // pi 判据:fileStats 命中
124
+ })
125
+
126
+ it('zcode 节点与 pi 孤儿节点并存:判据互不污染(不同构声明断言)', () => {
127
+ // pi 孤儿:identity 在但 fileStats 不含 → cleanedUp=true(JSONL GC)
128
+ const piOrphan: Entry = {
129
+ type: 'custom',
130
+ id: 'sa-pi-orphan',
131
+ parentId: null,
132
+ customType: 'subagent-identity',
133
+ data: { rootSessionId: ROOT, slug: 'orphan' },
134
+ }
135
+ const index = buildFamilyIndex([header(ROOT)], [piOrphan], new Map(), [
136
+ zcodeNode({ id: 'sa-zcode-gone', dbFileExists: false }),
137
+ ])
138
+ const subs = resolveFamily(ROOT, index).subagents
139
+ // 两节点都 cleanedUp=true,但判据来源不同:pi 走 fileStats,zcode 走 dbFileExists
140
+ expect(subs.find((s) => s.sessionId === 'sa-pi-orphan')?.cleanedUp).toBe(true)
141
+ expect(subs.find((s) => s.sessionId === 'sa-zcode-gone')?.cleanedUp).toBe(true)
142
+ })
143
+
144
+ it('SubagentRef 字段映射:富字段透传、sessionFile 不设、mtime/sizeBytes 占位 0', () => {
145
+ const byRoot = buildZcodeSubagentsByRoot([
146
+ zcodeNode({
147
+ id: 'sa-rich',
148
+ slug: 'rich-slug',
149
+ agentName: 'explorer',
150
+ task: 'do research',
151
+ model: 'glm-5.3',
152
+ status: 'closed',
153
+ dbFileExists: true,
154
+ }),
155
+ ])
156
+ const ref = byRoot.get(ROOT)?.[0]
157
+ expect(ref?.slug).toBe('rich-slug')
158
+ expect(ref?.agentName).toBe('explorer')
159
+ expect(ref?.task).toBe('do research')
160
+ expect(ref?.model).toBe('glm-5.3')
161
+ expect(ref?.status).toBe('closed')
162
+ expect(ref?.sessionFile).toBeUndefined() // zcode 无 session.jsonl
163
+ expect(ref?.mtime).toBe(0)
164
+ expect(ref?.sizeBytes).toBe(0)
165
+ })
166
+ })
167
+
168
+ describe('集成:listZcodeManifests → buildFamilyIndex → resolveFamily(场景 2 两态 + 反向断言)', () => {
169
+ it('库可达态(dbPath 指向真实 fixture 库)→ cleanedUp=false', async () => {
170
+ const dbPath = join(dir, 'isolated.sqlite')
171
+ createFixtureDb(dbPath, 'sess_live')
172
+ writeZcodeManifest(dir, 'sa-live', { sessionId: 'sess_live', dbPath })
173
+
174
+ const nodes = await listZcodeManifests(dir)
175
+ expect(nodes).toHaveLength(1)
176
+ expect(nodes[0]?.dbFileExists).toBe(true)
177
+
178
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
179
+ const sub = family.subagents.find((s) => s.sessionId === 'sa-live')
180
+ expect(sub?.cleanedUp).toBe(false)
181
+ })
182
+
183
+ it('库不存在态(dbPath 指向不存在路径)→ cleanedUp=true(GC 语义,不报错)', async () => {
184
+ writeZcodeManifest(dir, 'sa-gone', {
185
+ sessionId: 'sess_gone',
186
+ dbPath: join(dir, 'subagents', 'proj-a', 'missing.sqlite'),
187
+ })
188
+
189
+ const nodes = await listZcodeManifests(dir)
190
+ expect(nodes[0]?.dbFileExists).toBe(false)
191
+
192
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
193
+ expect(family.subagents.find((s) => s.sessionId === 'sa-gone')?.cleanedUp).toBe(true)
194
+ })
195
+
196
+ it('反向断言:库文件在但库内 session 行被删 → 仍 cleanedUp=false(列表视图不开库的分工声明)', async () => {
197
+ // 分工(D5-1):库内 session 已被 zcode GC 的情形由 read 路径 zcode_session_not_found
198
+ // 权威承接;family 只 stat 文件存在性。本用例删行留文件,断言 family 不受库内容影响。
199
+ const dbPath = join(dir, 'gc.sqlite')
200
+ createFixtureDb(dbPath, 'sess_victim')
201
+ writeZcodeManifest(dir, 'sa-gced', { sessionId: 'sess_victim', dbPath })
202
+
203
+ // 前置核实:fixture 库里确实有该 session 行(反向断言的非平凡性前提)
204
+ {
205
+ const db = new DatabaseSync(dbPath)
206
+ try {
207
+ const row = db.prepare('SELECT COUNT(*) AS c FROM session WHERE id = ?').get('sess_victim') as
208
+ | { c: number }
209
+ | undefined
210
+ expect(row?.c).toBe(1)
211
+ } finally {
212
+ db.close()
213
+ }
214
+ }
215
+
216
+ // 删库内 session 行(含 message/part 级联),db 文件保留
217
+ {
218
+ const db = new DatabaseSync(dbPath)
219
+ try {
220
+ db.prepare('DELETE FROM part WHERE session_id = ?').run('sess_victim')
221
+ db.prepare('DELETE FROM message WHERE session_id = ?').run('sess_victim')
222
+ db.prepare('DELETE FROM session WHERE id = ?').run('sess_victim')
223
+ } finally {
224
+ db.close()
225
+ }
226
+ }
227
+
228
+ const nodes = await listZcodeManifests(dir)
229
+ expect(nodes[0]?.dbFileExists).toBe(true) // 只 stat:文件在即 true,不看内容
230
+
231
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
232
+ expect(family.subagents.find((s) => s.sessionId === 'sa-gced')?.cleanedUp).toBe(false)
233
+ })
234
+
235
+ it('坏 manifest 与缺库节点混排 → 好节点照常输出(单节点不可达不炸整屏)', async () => {
236
+ const dbPath = join(dir, 'ok.sqlite')
237
+ createFixtureDb(dbPath, 'sess_ok')
238
+ writeZcodeManifest(dir, 'sa-ok', { sessionId: 'sess_ok', dbPath })
239
+ // 缺库节点:合法 zcode 形态(dbFileExists=false,不抛错),不剔除出列表
240
+ writeZcodeManifest(dir, 'sa-broken-manifest', { sessionId: 'sess_x', dbPath: '/nonexistent/x.sqlite' })
241
+ // 坏 JSON manifest:枚举跳过(不中断)
242
+ writeFileSync(
243
+ join(dir, 'subagents', 'proj-a', 'records', 'sa-corrupt.json'),
244
+ '{{{broken',
245
+ 'utf8',
246
+ )
247
+
248
+ const nodes = await listZcodeManifests(dir)
249
+ expect(nodes.map((n) => n.id).sort()).toEqual(['sa-broken-manifest', 'sa-ok'])
250
+
251
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
252
+ expect(family.subagents.find((s) => s.sessionId === 'sa-ok')?.cleanedUp).toBe(false)
253
+ expect(family.subagents.find((s) => s.sessionId === 'sa-broken-manifest')?.cleanedUp).toBe(true)
254
+ })
255
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
 
5
5
  // ---- fixture 常量(映射 design §3.3 D-7 Q1 真实场景)----
6
6
  const ROOT = '019fe620' // 家族根(无 parentSession)
@@ -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),
@@ -186,6 +186,29 @@ describe('findSessions', () => {
186
186
  expect(matches[0].cwd).toBe('/proj-a')
187
187
  })
188
188
 
189
+ it('缺 cwd 的 header 进候选集(谓词不统一负面锚,design §4 负面行为验证)', async () => {
190
+ // header 行只有 id(无 cwd/timestamp):runtime 导入侧「id+cwd 非空」严谓词会拒收,
191
+ // reader 宽谓词(仅要求 type==='session' ∧ id 为 string)必须照常收录——基座化
192
+ // (readFirstJsonlLine 改指 session-core)不得顺手统一谓词,本用例即机器锚。
193
+ const headerPath = join(slugDir, 'no-cwd.jsonl')
194
+ await mkdir(slugDir, { recursive: true })
195
+ await writeFile(headerPath, JSON.stringify({ type: 'session', id: '019e0001-no-cwd' }) + '\n')
196
+ await makeSession(slugDir, { name: 'with-cwd.jsonl', id: '019e0002-with-cwd', cwd: '/demo' })
197
+
198
+ // find 结果:缺 cwd header 命中,cwd 归一化为空串(buildCandidate 的 header.cwd ?? '')
199
+ const { matches } = await findSessions('no-cwd', agentDir)
200
+ expect(matches).toHaveLength(1)
201
+ expect(matches[0].sessionId).toBe('019e0001-no-cwd')
202
+ expect(matches[0].cwd).toBe('')
203
+
204
+ // 候选枚举(recent 路径枚举全部候选):缺 cwd header 未被候选集排除
205
+ const recent = await findSessions('recent', agentDir)
206
+ expect(recent.matches.map((m) => m.sessionId).sort()).toEqual([
207
+ '019e0001-no-cwd',
208
+ '019e0002-with-cwd',
209
+ ])
210
+ })
211
+
189
212
  it('limit 截断:truncated 标记正确', async () => {
190
213
  // 5 个文件 id 都含 "common",uuid 片段 "common" 全匹配
191
214
  for (let i = 0; i < 5; i++) {
@@ -511,61 +534,21 @@ describe('findSessions', () => {
511
534
  // B 不入选(uuid 命中短路,不走 task 匹配)
512
535
  expect(matches.some((m) => m.sessionId === idB)).toBe(false)
513
536
  })
514
-
515
- it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
516
- 'TC-u5-real-data-guard:find codex source:subagent 命中(本机有 codex 相关 subagent task)',
517
- async () => {
518
- const { matches, truncated } = await findSessions('codex', REAL_AGENT_DIR, {
519
- source: 'subagent',
520
- limit: 50,
521
- })
522
- expect(matches.length).toBeGreaterThan(0)
523
- expect(matches.every((m) => m.source === 'subagent')).toBe(true)
524
- expect(typeof truncated).toBe('boolean')
525
- },
526
- REAL_DATA_TIMEOUT_MS,
527
- )
528
537
  })
529
538
 
530
- it.skipIf(!HAS_E6)('真实数据:e6c96 匹配 019e6c96 开头的 session', async () => {
531
- 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)
532
546
  expect(matches.length).toBeGreaterThan(0)
533
- expect(matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
534
- // 真实值校验
535
547
  const hit = matches.find((m) => m.sessionId.startsWith('019e6c96'))!
536
548
  expect(hit.fileName).toContain('019e6c96')
537
549
  expect(hit.mtime).toBeGreaterThan(0)
538
550
  expect(hit.sizeBytes).toBeGreaterThan(0)
539
- }, REAL_DATA_TIMEOUT_MS)
540
-
541
- it.skipIf(!HAS_E6)("真实数据:recent 返回最近 N 个,mtime 倒序,truncated=true", async () => {
542
- const { matches, truncated } = await findSessions('recent', REAL_AGENT_DIR, { limit: 5 })
543
- expect(matches.length).toBeGreaterThan(0)
544
- expect(matches.length).toBeLessThanOrEqual(5)
545
- // mtime 倒序
546
- for (let i = 1; i < matches.length; i++) {
547
- expect(matches[i - 1].mtime).toBeGreaterThanOrEqual(matches[i].mtime)
548
- }
549
- // 真实 session 文件远多于 5 → 截断
550
- expect(truncated).toBe(true)
551
- }, REAL_DATA_TIMEOUT_MS)
552
-
553
- it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
554
- "真实数据:source:'subagent' 能找到 completed subagent(§7 场景 1 find 部分)",
555
- async () => {
556
- const { matches } = await findSessions('recent', REAL_AGENT_DIR, {
557
- source: 'subagent',
558
- limit: 5,
559
- })
560
- expect(matches.length).toBeGreaterThan(0)
561
- // 全部 source==='subagent',fileName 在 subagents/ 目录下
562
- for (const m of matches) {
563
- expect(m.source).toBe('subagent')
564
- expect(m.fileName).toContain('subagents')
565
- }
566
- },
567
- REAL_DATA_TIMEOUT_MS,
568
- )
551
+ })
569
552
  })
570
553
 
571
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,7 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseSessionContent, parseSessionFile } from '../core/parser.js'
3
- import type { Entry } from '../core/parser.js'
4
- import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
2
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { parseSessionContent, parseSessionFile, type Entry } from '@zhushanwen/session-core'
5
6
 
6
7
  /** 构造单行 JSONL entry 字符串 */
7
8
  function line(obj: Record<string, unknown>): string {
@@ -158,14 +159,37 @@ describe('parseSessionContent', () => {
158
159
  })
159
160
 
160
161
  describe('parseSessionFile', () => {
161
- it.skipIf(!HAS_REAL_SESSION)('真实 session 019e6c96:1204 entries / 0 skipped / 非 partial', async () => {
162
- const result = await parseSessionFile(REAL_SESSION)
163
-
164
- expect(result.entries).toHaveLength(1204)
165
- expect(result.skippedLines).toBe(0)
166
- expect(result.lastLinePartial).toBe(false)
167
- // 5.4MB 量级
168
- expect(result.totalBytes).toBeGreaterThan(5_000_000)
169
- // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
170
- }, 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
+ })
171
195
  })
@@ -1,12 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { renderOutline, renderExpand, renderDetail, type ToolResultSummaryEntry } from '../core/render.js'
3
- import type { Entry } from '../core/parser.js'
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 { parseSessionFile } from '../core/parser.js'
7
- import { buildTreeView } from '../core/tree.js'
8
- import { segmentTurns } from '../core/turns.js'
9
- import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
10
6
 
11
7
  // ---- 构造助手 ----
12
8
 
@@ -204,25 +200,32 @@ describe('renderOutline', () => {
204
200
  expect(noBranch.lines).toEqual(['T000 · hi'])
205
201
  })
206
202
 
207
- it.skipIf(!HAS_REAL_SESSION)('8. 真实 019e6c96:outline tokenEstimate <= 1500 + assistantBrief/toolSummary 非空(v2 O1)', async () => {
208
- // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 变长,阈值 600→1500(design §3.3 D4)
209
- const parsed = await parseSessionFile(REAL_SESSION)
210
- const tree = buildTreeView(parsed.entries)
211
- const turns = segmentTurns(parsed.entries, new Set(tree.leafPath))
212
- 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 })
213
218
 
214
- expect(result.turns.length).toBe(32)
219
+ expect(result.turns.length).toBe(3)
215
220
  expect(result.tokenEstimate).toBeLessThanOrEqual(1500)
216
- // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示真实工具(修 v1 恒空 bug)
221
+ // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示工具(修 v1 恒空 bug)
217
222
  expect(result.turns.some((b) => b.assistantBrief !== '')).toBe(true)
218
223
  expect(result.turns.some((b) => b.toolSummary !== '')).toBe(true)
219
224
  // stats 完整性
220
- expect(result.stats.totalTurns).toBe(32)
221
- // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
222
- // 工具层仅覆盖 stats.totalBytes 与 skippedLines(ParseResult 无 totalEntries 字段)。M1 验量级。
223
- expect(result.stats.totalEntries).toBeGreaterThan(1000)
224
- // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
225
- }, 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
+ })
226
229
  })
227
230
 
228
231
  describe('renderExpand', () => {
@@ -267,10 +267,10 @@ describe('result action(U6)', () => {
267
267
  }
268
268
  })
269
269
 
270
- it('6. 不存在 id:sa-id 无 manifest → 无匹配 record;uuid 片段零匹配 → 无匹配 session', async () => {
270
+ it('6. 不存在 id:sa-id 无 manifest → zcode_record_not_found(U9 统一错误面);uuid 片段零匹配 → 无匹配 session', async () => {
271
271
  await expect(
272
272
  handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir }),
273
- ).rejects.toThrow(/无匹配 record/)
273
+ ).rejects.toThrow(/zcode_record_not_found/)
274
274
  try {
275
275
  await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir })
276
276
  } catch (e) {
@@ -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 并入:列实际扫描候选根)', () => {