@zhushanwen/pi-session-reader 0.1.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.
@@ -0,0 +1,430 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
+ import { execSync } from 'node:child_process'
5
+ import { join } from 'node:path'
6
+ import { buildFamilyFromFs } from '../discovery/subagents.js'
7
+
8
+ // ---- fixture 常量(uuid 特征,满足 extractSessionIdFromFilename + 互不为子串)----
9
+ const ROOT = '0aaaaaaa-bbbb-7ccc-dddd-000000000001'
10
+ const FORK = '0aaaaaaa-bbbb-7ccc-dddd-000000000002'
11
+ const SUB_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000003'
12
+
13
+ /** 真实 pi agent 目录(本机),用于集成测试。 */
14
+ const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
15
+
16
+ // 同步探测真实 session 是否存在(不存在则 skip,避免在无该数据的机器上硬失败)
17
+ function hasRealSession(sid: string): boolean {
18
+ try {
19
+ return (
20
+ execSync(
21
+ `find ${REAL_AGENT_DIR}/sessions -name '*${sid}*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
22
+ { encoding: 'utf8' },
23
+ ).trim().length > 0
24
+ )
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+ const HAS_REAL_ROOT = hasRealSession('019fe620-8ae1-78a7-b76a-43a1ba4cc3c7')
30
+ const HAS_REAL_WF = hasRealSession('019fdcda-75c7-74b7-a160-f67f6bf88384')
31
+
32
+ // ---- fixture helpers ----
33
+
34
+ async function makeAgentDir(): Promise<string> {
35
+ return mkdtemp(join(tmpdir(), 'subagents-test-'))
36
+ }
37
+
38
+ /** 写主 session 文件(首行 header)。返回绝对路径(供 fork 的 parentSession 指向)。 */
39
+ async function writeMainSession(
40
+ dir: string,
41
+ slug: string,
42
+ id: string,
43
+ opts?: { cwd?: string; parentSession?: string },
44
+ ): Promise<string> {
45
+ const sessionDir = join(dir, 'sessions', slug)
46
+ await mkdir(sessionDir, { recursive: true })
47
+ const path = join(sessionDir, `${id}.jsonl`)
48
+ const header: Record<string, unknown> = { type: 'session', id, cwd: opts?.cwd ?? `/proj/${slug}` }
49
+ if (opts?.parentSession) header.parentSession = opts.parentSession
50
+ await writeFile(path, JSON.stringify(header) + '\n')
51
+ return path
52
+ }
53
+
54
+ /**
55
+ * 写 subagent session 文件:首行 header(真实 id)+ 占位 message + 尾行 identity。
56
+ * identity 在尾行(实测 pi 行为;subagent-identity 由 session-runner 在 session 创建后写)。
57
+ */
58
+ async function writeSubagentSession(
59
+ dir: string,
60
+ slug: string,
61
+ realId: string,
62
+ identity: { rootSessionId: string; slug: string; dataId?: string },
63
+ ): Promise<string> {
64
+ const sessionDir = join(dir, 'subagents', slug, 'sessions')
65
+ await mkdir(sessionDir, { recursive: true })
66
+ const path = join(sessionDir, `${realId}.jsonl`)
67
+ const lines = [
68
+ JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${slug}` }),
69
+ JSON.stringify({
70
+ type: 'message',
71
+ id: 'm1',
72
+ parentId: realId,
73
+ message: { role: 'user', content: 'do work' },
74
+ }),
75
+ JSON.stringify({
76
+ type: 'custom',
77
+ customType: 'subagent-identity',
78
+ data: {
79
+ id: identity.dataId ?? `sa-${realId.slice(0, 8)}`,
80
+ rootSessionId: identity.rootSessionId,
81
+ slug: identity.slug,
82
+ agent: 'explorer',
83
+ mode: 'sync',
84
+ task: 't',
85
+ startedAt: 1,
86
+ },
87
+ }),
88
+ ]
89
+ await writeFile(path, lines.join('\n') + '\n')
90
+ return path
91
+ }
92
+
93
+ /** 写 wf-state 文件(每行一个快照;字符串按原样写入,可注入坏行)。返回绝对路径。 */
94
+ async function writeWfState(dir: string, slug: string, lines: string[]): Promise<string> {
95
+ const wfDir = join(dir, 'sessions', slug, 'workflow-state')
96
+ await mkdir(wfDir, { recursive: true })
97
+ const path = join(wfDir, 'wf-test.jsonl')
98
+ await writeFile(path, lines.join('\n') + '\n')
99
+ return path
100
+ }
101
+
102
+ /** 向主 session 文件追加 workflow-state-link custom entry(resolveWorkflows 的输入)。 */
103
+ async function writeWfLink(
104
+ dir: string,
105
+ slug: string,
106
+ id: string,
107
+ link: { runId: string; path: string },
108
+ ): Promise<void> {
109
+ const sessionPath = join(dir, 'sessions', slug, `${id}.jsonl`)
110
+ const line = JSON.stringify({
111
+ type: 'custom',
112
+ id: `wf-link-${link.runId}`,
113
+ parentId: id,
114
+ customType: 'workflow-state-link',
115
+ data: { runId: link.runId, path: link.path, updatedAt: '2026-08-07T16:48:24.933Z' },
116
+ timestamp: '2026-08-07T16:48:24.933Z',
117
+ })
118
+ await writeFile(sessionPath, line + '\n', { flag: 'a' })
119
+ }
120
+
121
+ /** 写 records manifest(孤儿源)。 */
122
+ async function writeRecordManifest(
123
+ dir: string,
124
+ slug: string,
125
+ id: string,
126
+ fields: { rootSessionId: string; agentName?: string; sessionFile: string },
127
+ ): Promise<void> {
128
+ const recordsDir = join(dir, 'subagents', slug, 'records')
129
+ await mkdir(recordsDir, { recursive: true })
130
+ await writeFile(join(recordsDir, `${id}.json`), JSON.stringify({ id, ...fields }))
131
+ }
132
+
133
+ // ============================================================
134
+ // fixture 测试
135
+ // ============================================================
136
+
137
+ describe('buildFamilyFromFs - fixture', () => {
138
+ let dir: string
139
+
140
+ beforeEach(async () => {
141
+ dir = await makeAgentDir()
142
+ })
143
+ afterEach(async () => {
144
+ await rm(dir, { recursive: true, force: true })
145
+ })
146
+
147
+ it('基础 family:root + fork + 隔代 subagent,SubagentRef.sessionId 是真实 id(非 sa-xxx)', async () => {
148
+ const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
149
+ // fork 在不同 cwd slug(模拟跨 cwd fork)
150
+ await writeMainSession(dir, '--fork-cwd--', FORK, {
151
+ cwd: '/proj/fork',
152
+ parentSession: rootPath,
153
+ })
154
+ // subagent 挂在 fork 子代下(rootSessionId=FORK,非 ROOT)→ 隔代
155
+ await writeSubagentSession(dir, '--fork-cwd--', SUB_REAL, {
156
+ rootSessionId: FORK,
157
+ slug: 'test-sub',
158
+ dataId: 'sa-placeholder-1',
159
+ })
160
+
161
+ const family = await buildFamilyFromFs(ROOT, dir)
162
+
163
+ expect(family.root.sessionId).toBe(ROOT)
164
+ // fork:parentSession 含 ROOT id → childrenOf[ROOT] = [FORK]
165
+ expect(family.forks.some((f) => f.sessionId === FORK)).toBe(true)
166
+ // Q1 隔代:subagent rootSessionId=FORK(fork 子代),从 ROOT resolve 能关联
167
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
168
+ expect(sub).toBeDefined()
169
+ // id 修正核心断言:sessionId 是 subagent 文件首行 header.id(真实),非 identity.data.id 的 sa-xxx
170
+ expect(sub!.sessionId).toBe(SUB_REAL)
171
+ expect(sub!.sessionId.startsWith('sa-')).toBe(false)
172
+ expect(sub!.rootSessionId).toBe(FORK)
173
+ expect(sub!.slug).toBe('test-sub')
174
+ expect(sub!.cleanedUp).toBe(false)
175
+ // enrich:fileName/cwd 已补真实值(非 M1 占位空串)
176
+ expect(sub!.fileName.length).toBeGreaterThan(0)
177
+ expect(sub!.cwd).toBe('/proj/--fork-cwd--')
178
+ })
179
+
180
+ it('从 fork 子代 resolve 也能关联到挂在其下的 subagent', async () => {
181
+ const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT)
182
+ await writeMainSession(dir, '--fork-cwd--', FORK, { parentSession: rootPath })
183
+ await writeSubagentSession(dir, '--fork-cwd--', SUB_REAL, { rootSessionId: FORK, slug: 's' })
184
+
185
+ const family = await buildFamilyFromFs(FORK, dir)
186
+ expect(family.subagents.some((s) => s.sessionId === SUB_REAL)).toBe(true)
187
+ })
188
+
189
+ it('cleanedUp:manifest 孤儿(.jsonl 不存在)→ cleanedUp=true', async () => {
190
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
191
+ // 孤儿 manifest:rootSessionId=ROOT,sessionFile 指向不存在路径(模拟 .jsonl 被 GC)
192
+ await writeRecordManifest(dir, '--root-cwd--', 'sa-ghost-id', {
193
+ rootSessionId: ROOT,
194
+ agentName: 'explorer',
195
+ sessionFile: '/nonexistent/ghost.jsonl',
196
+ })
197
+
198
+ const family = await buildFamilyFromFs(ROOT, dir)
199
+
200
+ const ghost = family.subagents.find((s) => s.sessionId === 'sa-ghost-id')
201
+ expect(ghost).toBeDefined()
202
+ expect(ghost!.cleanedUp).toBe(true)
203
+ expect(ghost!.rootSessionId).toBe(ROOT)
204
+ // 孤儿无文件 → mtime/size 占位 0
205
+ expect(ghost!.mtime).toBe(0)
206
+ expect(ghost!.sizeBytes).toBe(0)
207
+ })
208
+
209
+ it('alive subagent 同时有 manifest → 不重复计数(manifest 跳过 alive)', async () => {
210
+ const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT)
211
+ await writeMainSession(dir, '--fork-cwd--', FORK, { parentSession: rootPath })
212
+ const subPath = await writeSubagentSession(dir, '--fork-cwd--', SUB_REAL, {
213
+ rootSessionId: FORK,
214
+ slug: 'dup-test',
215
+ })
216
+ // manifest 指向真实文件路径(alive)→ 应被跳过,不产生孤儿副本
217
+ await writeRecordManifest(dir, '--fork-cwd--', `sa-${SUB_REAL}`, {
218
+ rootSessionId: FORK,
219
+ agentName: 'explorer',
220
+ sessionFile: subPath,
221
+ })
222
+
223
+ const family = await buildFamilyFromFs(ROOT, dir)
224
+ // 只有一个 SUB_REAL(真实 id),无 sa- 副本
225
+ const realOnes = family.subagents.filter((s) => s.sessionId === SUB_REAL)
226
+ expect(realOnes).toHaveLength(1)
227
+ expect(realOnes[0].cleanedUp).toBe(false)
228
+ const orphans = family.subagents.filter((s) => s.sessionId.startsWith('sa-'))
229
+ expect(orphans).toHaveLength(0)
230
+ })
231
+
232
+ it('sessionId 不在任意 main header → 抛 Error', async () => {
233
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
234
+ await expect(buildFamilyFromFs('nonexistent-session-id', dir)).rejects.toThrow(/not found/)
235
+ })
236
+
237
+ it('workflows:NEW 格式(v=wf-run-v1)state.calls 解析;命中 pathToRef 取完整 ref,GC\'d 路径回退最小 ref', async () => {
238
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
239
+ // 真实存在的 subagent(步骤 2 扫到 → pathToRef 命中 → 完整 SessionRef)
240
+ const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
241
+ rootSessionId: ROOT,
242
+ slug: 'wf-sub',
243
+ })
244
+ // GC\'d 路径(文件不存在 → pathToRef 未命中 → sessionRefFromPath 最小 ref)
245
+ const gced = join(
246
+ dir,
247
+ 'subagents',
248
+ '--root-cwd--',
249
+ 'sessions',
250
+ '2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl',
251
+ )
252
+ const wfPath = await writeWfState(dir, '--root-cwd--', [
253
+ JSON.stringify({
254
+ v: 'wf-run-v1',
255
+ runId: 'wf-1786121304924-r7vgov',
256
+ state: {
257
+ status: 'done',
258
+ calls: [
259
+ { id: 0, status: 'done', sessionFile: subPath, sessionId: 'sa-x' },
260
+ { id: 1, status: 'done', result: { sessionFile: gced, durationMs: 1 } },
261
+ ],
262
+ },
263
+ }),
264
+ ])
265
+ await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-1786121304924-r7vgov', path: wfPath })
266
+
267
+ const family = await buildFamilyFromFs(ROOT, dir)
268
+
269
+ expect(family.workflows).toHaveLength(1)
270
+ const wf = family.workflows[0]
271
+ expect(wf.runId).toBe('wf-1786121304924-r7vgov')
272
+ expect(wf.stateFile).toBe(wfPath)
273
+ expect(wf.calls).toHaveLength(2)
274
+ // 命中 pathToRef:完整 ref(真实 id / mtime / size / cwd)
275
+ expect(wf.calls[0].fileName).toBe(subPath)
276
+ expect(wf.calls[0].sessionId).toBe(SUB_REAL)
277
+ expect(wf.calls[0].mtime).toBeGreaterThan(0)
278
+ expect(wf.calls[0].sizeBytes).toBeGreaterThan(0)
279
+ expect(wf.calls[0].cwd).toBe('/proj/--root-cwd--')
280
+ // GC\'d 未命中:fileName-only 最小 ref(sessionId 从文件名提取,mtime/size/cwd 占位)
281
+ expect(wf.calls[1].fileName).toBe(gced)
282
+ expect(wf.calls[1].sessionId).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
283
+ expect(wf.calls[1].mtime).toBe(0)
284
+ expect(wf.calls[1].sizeBytes).toBe(0)
285
+ expect(wf.calls[1].cwd).toBe('')
286
+ })
287
+
288
+ it('workflows:NEW 格式坏尾行回退上一快照;顶层 sessionFile 优先于 result.sessionFile', async () => {
289
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
290
+ const topLevel = join(
291
+ dir,
292
+ 'subagents',
293
+ '--root-cwd--',
294
+ 'sessions',
295
+ '2026-08-01T00-00-00-000Z_019fdd11-1111-1111-1111-111111111111.jsonl',
296
+ )
297
+ const inResult = join(
298
+ dir,
299
+ 'subagents',
300
+ '--root-cwd--',
301
+ 'sessions',
302
+ '2026-08-02T00-00-00-000Z_019fdd22-2222-2222-2222-222222222222.jsonl',
303
+ )
304
+ const snap = JSON.stringify({
305
+ v: 'wf-run-v1',
306
+ runId: 'wf-x',
307
+ state: { calls: [{ id: 0, sessionFile: topLevel, result: { sessionFile: inResult } }] },
308
+ })
309
+ // 尾行坏 JSON → readWorkflowCallSessionFiles 从尾向头回退到上一有效快照
310
+ const wfPath = await writeWfState(dir, '--root-cwd--', [snap, '{broken json'])
311
+ await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-x', path: wfPath })
312
+
313
+ const family = await buildFamilyFromFs(ROOT, dir)
314
+
315
+ expect(family.workflows).toHaveLength(1)
316
+ expect(family.workflows[0].calls).toHaveLength(1)
317
+ // 顶层 sessionFile 优先(result.sessionFile 不覆盖)
318
+ expect(family.workflows[0].calls[0].fileName).toBe(topLevel)
319
+ })
320
+
321
+ it('workflows:OLD 格式(无 v)callCache [{key,value}] → value.sessionFile + value.result.sessionFile 回退;无 sessionFile 的 call 不产出', async () => {
322
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
323
+ const viaValue = join(
324
+ dir,
325
+ 'subagents',
326
+ '--root-cwd--',
327
+ 'sessions',
328
+ '2026-08-03T00-00-00-000Z_019fdd33-3333-3333-3333-333333333333.jsonl',
329
+ )
330
+ const viaResult = join(
331
+ dir,
332
+ 'subagents',
333
+ '--root-cwd--',
334
+ 'sessions',
335
+ '2026-08-04T00-00-00-000Z_019fdd44-4444-4444-4444-444444444444.jsonl',
336
+ )
337
+ const wfPath = await writeWfState(dir, '--root-cwd--', [
338
+ JSON.stringify({
339
+ runId: 'wf-old-1',
340
+ name: 'old-wf',
341
+ status: 'done',
342
+ callCache: [
343
+ // 真实 OLD 数据形态:value 无 sessionFile(旧 pi 不持久化)→ 不产出
344
+ { key: 1, value: { content: 'PASS', durationMs: 100 } },
345
+ // value.sessionFile(源码注释 OLD 分支读取点)
346
+ { key: 2, value: { sessionFile: viaValue, content: 'ok' } },
347
+ // value.result.sessionFile 回退
348
+ { key: 3, value: { result: { sessionFile: viaResult, durationMs: 1 } } },
349
+ // value 非对象 → 整项兜底(无 sessionFile → 不产出)
350
+ { key: 4, value: 'str' },
351
+ ],
352
+ }),
353
+ ])
354
+ await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-old-1', path: wfPath })
355
+
356
+ const family = await buildFamilyFromFs(ROOT, dir)
357
+
358
+ expect(family.workflows).toHaveLength(1)
359
+ expect(family.workflows[0].calls.map((c) => c.fileName)).toEqual([viaValue, viaResult])
360
+ // 未命中 pathToRef → 最小 ref:sessionId 从文件名提取,mtime 占位 0
361
+ expect(family.workflows[0].calls[0].sessionId).toBe('019fdd33-3333-3333-3333-333333333333')
362
+ expect(family.workflows[0].calls[0].mtime).toBe(0)
363
+ })
364
+
365
+ it('MF-3 回归:alive 但无 identity 的 subagent 文件(运行中)不被 manifest 收编为 cleanedUp', async () => {
366
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
367
+ // 运行中 subagent:有效 header、无 identity 尾行(identity 完成时才写入),manifest 已存在
368
+ const subDir = join(dir, 'subagents', '--root-cwd--', 'sessions')
369
+ await mkdir(subDir, { recursive: true })
370
+ const subPath = join(subDir, SUB_REAL + '.jsonl')
371
+ await writeFile(
372
+ subPath,
373
+ JSON.stringify({ type: 'session', id: SUB_REAL, cwd: '/proj/root' }) + '\n',
374
+ )
375
+ await writeRecordManifest(dir, '--root-cwd--', `sa-${SUB_REAL}`, {
376
+ rootSessionId: ROOT,
377
+ agentName: 'explorer',
378
+ sessionFile: subPath,
379
+ })
380
+
381
+ const family = await buildFamilyFromFs(ROOT, dir)
382
+ // 旧实现:无 identity → 步骤 2 跳过 → 步骤 3 按孤儿收编 → 活 subagent 显示 [已清理]
383
+ // 新实现:header 有效即 alive → manifest 跳过 → 无 identity 无法关联 rootSessionId,不入列表
384
+ const subs = family.subagents.filter(
385
+ (s) => s.sessionId === SUB_REAL || s.sessionId.startsWith('sa-'),
386
+ )
387
+ expect(subs).toHaveLength(0)
388
+ expect(family.subagents.every((s) => s.cleanedUp === false)).toBe(true)
389
+ })
390
+ })
391
+
392
+ // ============================================================
393
+ // 真实数据集成测试(~/.pi/agent)
394
+ // ============================================================
395
+
396
+ describe.skipIf(!HAS_REAL_ROOT)('buildFamilyFromFs - 真实数据 ~/.pi/agent', () => {
397
+ it('019fe620 family:fork 019fe632(跨 cwd)+ 隔代 subagent 019fe635(真实 id)', async () => {
398
+ const family = await buildFamilyFromFs(
399
+ '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7',
400
+ REAL_AGENT_DIR,
401
+ )
402
+ // fork 019fe632(cwd feat-optimize-todo-goal,与 root 的 fix-cw-tool-wroktree 不同)
403
+ const fork = family.forks.find((f) => f.sessionId.startsWith('019fe632'))
404
+ expect(fork).toBeDefined()
405
+ // 隔代 subagent:rootSessionId=019fe632(fork 子代),sessionId 真实(019fe635 开头,非 sa-)
406
+ const sub = family.subagents.find(
407
+ (s) => s.rootSessionId.startsWith('019fe632') && s.sessionId.startsWith('019fe635'),
408
+ )
409
+ expect(sub).toBeDefined()
410
+ expect(sub!.sessionId.startsWith('sa-')).toBe(false)
411
+ }, 30000)
412
+ })
413
+
414
+ describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () => {
415
+ it('019fdcda:workflows 非空,至少一个 workflow calls>=4', async () => {
416
+ const family = await buildFamilyFromFs(
417
+ '019fdcda-75c7-74b7-a160-f67f6bf88384',
418
+ REAL_AGENT_DIR,
419
+ )
420
+ expect(family.workflows.length).toBeGreaterThan(0)
421
+ // wf-1786121387659-5voqzc 有 4 个 agent() calls(sessionFile 持久化)
422
+ const rich = family.workflows.find((w) => w.calls.length >= 4)
423
+ expect(rich).toBeDefined()
424
+ // calls 的 sessionFile 路径已落入 fileName(sessionRefFromPath)
425
+ expect(rich!.calls.every((c) => c.fileName.length > 0)).toBe(true)
426
+ // stateFile 是 wf-state 文件绝对路径
427
+ expect(rich!.stateFile.endsWith('.jsonl')).toBe(true)
428
+ expect(rich!.runId.startsWith('wf-')).toBe(true)
429
+ }, 30000)
430
+ })