@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
@@ -1,9 +1,15 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
+ import { existsSync } from 'node:fs'
3
4
  import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
5
  import { execSync } from 'node:child_process'
5
6
  import { join } from 'node:path'
6
- import { buildFamilyFromFs } from '../discovery/subagents.js'
7
+ import {
8
+ buildFamilyFromFs,
9
+ listRecordManifests,
10
+ extractSessionIdFromFilename,
11
+ type RecordManifest,
12
+ } from '../discovery/subagents.js'
7
13
 
8
14
  // ---- fixture 常量(uuid 特征,满足 extractSessionIdFromFilename + 互不为子串)----
9
15
  const ROOT = '0aaaaaaa-bbbb-7ccc-dddd-000000000001'
@@ -26,6 +32,21 @@ function hasRealSession(sid: string): boolean {
26
32
  return false
27
33
  }
28
34
  }
35
+
36
+ // 双目录版探测:subagent session 文件在 subagents/ 下,hasRealSession 扫不到。
37
+ // 用于对活跃数据目录中具体文件(fork 子代/隔代 subagent)存在性的守卫。
38
+ function hasAnyRealSession(fragment: string): boolean {
39
+ try {
40
+ return (
41
+ execSync(
42
+ `find ${REAL_AGENT_DIR}/sessions ${REAL_AGENT_DIR}/subagents -name '*${fragment}*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
43
+ { encoding: 'utf8' },
44
+ ).trim().length > 0
45
+ )
46
+ } catch {
47
+ return false
48
+ }
49
+ }
29
50
  const HAS_REAL_ROOT = hasRealSession('019fe620-8ae1-78a7-b76a-43a1ba4cc3c7')
30
51
  const HAS_REAL_WF = hasRealSession('019fdcda-75c7-74b7-a160-f67f6bf88384')
31
52
 
@@ -54,12 +75,13 @@ async function writeMainSession(
54
75
  /**
55
76
  * 写 subagent session 文件:首行 header(真实 id)+ 占位 message + 尾行 identity。
56
77
  * identity 在尾行(实测 pi 行为;subagent-identity 由 session-runner 在 session 创建后写)。
78
+ * U4 扩展:identity.task/agent 可定制(P-fallback 测试用,默认 't'/'explorer')。
57
79
  */
58
80
  async function writeSubagentSession(
59
81
  dir: string,
60
82
  slug: string,
61
- realId: string,
62
- identity: { rootSessionId: string; slug: string; dataId?: string },
83
+ realId: string,
84
+ identity: { rootSessionId: string; slug: string; dataId?: string; task?: string; agent?: string },
63
85
  ): Promise<string> {
64
86
  const sessionDir = join(dir, 'subagents', slug, 'sessions')
65
87
  await mkdir(sessionDir, { recursive: true })
@@ -79,9 +101,9 @@ async function writeSubagentSession(
79
101
  id: identity.dataId ?? `sa-${realId.slice(0, 8)}`,
80
102
  rootSessionId: identity.rootSessionId,
81
103
  slug: identity.slug,
82
- agent: 'explorer',
104
+ agent: identity.agent ?? 'explorer',
83
105
  mode: 'sync',
84
- task: 't',
106
+ task: identity.task ?? 't',
85
107
  startedAt: 1,
86
108
  },
87
109
  }),
@@ -90,6 +112,31 @@ async function writeSubagentSession(
90
112
  return path
91
113
  }
92
114
 
115
+ /**
116
+ * 写 alive subagent 文件但**无 identity 尾行**(运行中/异常,尾行是 message)。
117
+ * TC-u4-pfallback-no-identity 场景:header 有效、无 manifest、无 identity → buildFamilyFromFs 跳过。
118
+ */
119
+ async function writeAliveSubagentNoIdentity(
120
+ dir: string,
121
+ slug: string,
122
+ realId: string,
123
+ ): Promise<string> {
124
+ const sessionDir = join(dir, 'subagents', slug, 'sessions')
125
+ await mkdir(sessionDir, { recursive: true })
126
+ const path = join(sessionDir, `${realId}.jsonl`)
127
+ const lines = [
128
+ JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${slug}` }),
129
+ JSON.stringify({
130
+ type: 'message',
131
+ id: 'm1',
132
+ parentId: realId,
133
+ message: { role: 'user', content: 'still running' },
134
+ }),
135
+ ]
136
+ await writeFile(path, lines.join('\n') + '\n')
137
+ return path
138
+ }
139
+
93
140
  /** 写 wf-state 文件(每行一个快照;字符串按原样写入,可注入坏行)。返回绝对路径。 */
94
141
  async function writeWfState(dir: string, slug: string, lines: string[]): Promise<string> {
95
142
  const wfDir = join(dir, 'sessions', slug, 'workflow-state')
@@ -118,12 +165,20 @@ async function writeWfLink(
118
165
  await writeFile(sessionPath, line + '\n', { flag: 'a' })
119
166
  }
120
167
 
121
- /** 写 records manifest(孤儿源)。 */
168
+ /** 写 records manifest(孤儿源)。U4 扩展:fields 支持富字段 task/slug/model/status。 */
122
169
  async function writeRecordManifest(
123
170
  dir: string,
124
171
  slug: string,
125
172
  id: string,
126
- fields: { rootSessionId: string; agentName?: string; sessionFile: string },
173
+ fields: {
174
+ rootSessionId: string
175
+ agentName?: string
176
+ sessionFile: string
177
+ task?: string
178
+ slug?: string
179
+ model?: string
180
+ status?: string
181
+ },
127
182
  ): Promise<void> {
128
183
  const recordsDir = join(dir, 'subagents', slug, 'records')
129
184
  await mkdir(recordsDir, { recursive: true })
@@ -362,7 +417,7 @@ describe('buildFamilyFromFs - fixture', () => {
362
417
  expect(family.workflows[0].calls[0].mtime).toBe(0)
363
418
  })
364
419
 
365
- it('MF-3 回归:alive 但无 identity 的 subagent 文件(运行中)不被 manifest 收编为 cleanedUp', async () => {
420
+ it('MF-3 回归:alive 但无 identity 的 subagent(运行中)不被收编为 cleanedUp——U4 manifest 主路径建族', async () => {
366
421
  await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
367
422
  // 运行中 subagent:有效 header、无 identity 尾行(identity 完成时才写入),manifest 已存在
368
423
  const subDir = join(dir, 'subagents', '--root-cwd--', 'sessions')
@@ -379,12 +434,17 @@ describe('buildFamilyFromFs - fixture', () => {
379
434
  })
380
435
 
381
436
  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)
437
+ // U4 语义变化(TC-manifest-source):m0 alive identity完全丢弃(无数据源建族)。
438
+ // U4 manifest 主路径:manifest 命中即建族(manifest rootSessionId),不再依赖 identity
439
+ // MF-3 核心保护仍生效:不被收编为 cleanedUp(fileStats 有 realId → cleanedUp=false)。
440
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
441
+ expect(sub).toBeDefined()
442
+ expect(sub!.cleanedUp).toBe(false) // 核心:运行中不被当孤儿
443
+ expect(sub!.rootSessionId).toBe(ROOT) // manifest 主:rootSessionId 从 manifest 透传
444
+ expect(sub!.agentName).toBe('explorer') // manifest.agentName → agentName
445
+ // 无 identity 尾行 → task/model/status 取决于 manifest(本 fixture manifest 未写这些 → undefined)
446
+ expect(sub!.task).toBeUndefined()
447
+ expect(sub!.model).toBeUndefined()
388
448
  expect(family.subagents.every((s) => s.cleanedUp === false)).toBe(true)
389
449
  })
390
450
  })
@@ -395,6 +455,9 @@ describe('buildFamilyFromFs - fixture', () => {
395
455
 
396
456
  describe.skipIf(!HAS_REAL_ROOT)('buildFamilyFromFs - 真实数据 ~/.pi/agent', () => {
397
457
  it('019fe620 family:fork 019fe632(跨 cwd)+ 隔代 subagent 019fe635(真实 id)', async () => {
458
+ // 数据守卫:019fe632(sessions/)与 019fe635(subagents/)位于活跃数据目录,
459
+ // 被 GC/重命名时跳过而非失败(同 TC14-TC18 守卫模式),避免偶发红。
460
+ if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
398
461
  const family = await buildFamilyFromFs(
399
462
  '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7',
400
463
  REAL_AGENT_DIR,
@@ -413,6 +476,17 @@ describe.skipIf(!HAS_REAL_ROOT)('buildFamilyFromFs - 真实数据 ~/.pi/agent',
413
476
 
414
477
  describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () => {
415
478
  it('019fdcda:workflows 非空,至少一个 workflow calls>=4', async () => {
479
+ // 数据守卫:workflow-state 快照文件可能被清理(wf-state 是会话运行产物),
480
+ // 019fdcda 的 workflow-state-link 指向的快照不存在时跳过而非失败。
481
+ const wfFile = execSync(
482
+ `find ${REAL_AGENT_DIR}/sessions -name '*019fdcda*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
483
+ { encoding: 'utf8' },
484
+ ).trim()
485
+ if (!wfFile) return
486
+ const raw = execSync(`grep -c 'workflow-state-link' '${wfFile}' 2>/dev/null || true`, {
487
+ encoding: 'utf8',
488
+ }).trim()
489
+ if (raw === '0' || raw === '') return
416
490
  const family = await buildFamilyFromFs(
417
491
  '019fdcda-75c7-74b7-a160-f67f6bf88384',
418
492
  REAL_AGENT_DIR,
@@ -428,3 +502,238 @@ describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () =
428
502
  expect(rich!.runId.startsWith('wf-')).toBe(true)
429
503
  }, 30000)
430
504
  })
505
+
506
+ // ============================================================
507
+ // w2 TC1:listRecordManifests + RecordManifest 导出(IF2/DM2,行为零变更验证)
508
+ // ============================================================
509
+
510
+ describe('listRecordManifests 导出(w2 TC1)', () => {
511
+ let dir: string
512
+
513
+ beforeEach(async () => {
514
+ dir = await makeAgentDir()
515
+ })
516
+ afterEach(async () => {
517
+ await rm(dir, { recursive: true, force: true })
518
+ })
519
+
520
+ it('导出生效:import + 调用返回 RecordManifest[],字段完整', async () => {
521
+ await writeRecordManifest(dir, '--demo-cwd--', 'sa-tc1', {
522
+ rootSessionId: 'root-1',
523
+ agentName: 'explorer',
524
+ sessionFile: '/tmp/sa-tc1.jsonl',
525
+ })
526
+ const manifests: RecordManifest[] = await listRecordManifests(dir)
527
+ expect(manifests).toHaveLength(1)
528
+ const m = manifests[0]
529
+ expect(m.id).toBe('sa-tc1')
530
+ expect(m.rootSessionId).toBe('root-1')
531
+ expect(m.agentName).toBe('explorer')
532
+ expect(m.sessionFile).toBe('/tmp/sa-tc1.jsonl')
533
+ })
534
+ })
535
+
536
+ // ============================================================
537
+ // U4 端到端:buildFamilyFromFs 富化(manifest 主 / P-fallback identity 回退 / orphan / compat)
538
+ // 验证数据流重组:manifest 索引命中透全字段,未命中读尾行 identity 回退,孤儿 manifest 富字段+cleanedUp
539
+ // ============================================================
540
+
541
+ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
542
+ let dir: string
543
+
544
+ beforeEach(async () => {
545
+ dir = await makeAgentDir()
546
+ })
547
+ afterEach(async () => {
548
+ await rm(dir, { recursive: true, force: true })
549
+ })
550
+
551
+ it('TC-u4-manifest-enrich: alive + manifest 全字段 → SubagentRef 富字段全透传', async () => {
552
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
553
+ const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
554
+ rootSessionId: ROOT,
555
+ slug: 'identity-slug', // identity.slug,manifest 主时被 manifest.slug 覆盖
556
+ task: 'identity-task', // identity.task,manifest 主时被 manifest.task 覆盖
557
+ agent: 'worker', // identity.agent,manifest 主时被 manifest.agentName 覆盖
558
+ })
559
+ // manifest 全字段(命中 meta.path 索引 → 走 manifest 主路径,覆盖 identity 值)
560
+ await writeRecordManifest(dir, '--root-cwd--', `sa-${SUB_REAL}`, {
561
+ rootSessionId: ROOT,
562
+ agentName: 'explorer',
563
+ sessionFile: subPath,
564
+ task: '调研 codex',
565
+ slug: 'codex-ask-user-research',
566
+ model: 'glm-5.2',
567
+ status: 'completed',
568
+ })
569
+
570
+ const family = await buildFamilyFromFs(ROOT, dir)
571
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
572
+
573
+ expect(sub).toBeDefined()
574
+ // manifest 主:富字段从 manifest 透传(覆盖 identity 的值)
575
+ expect(sub!.task).toBe('调研 codex')
576
+ expect(sub!.slug).toBe('codex-ask-user-research')
577
+ expect(sub!.agentName).toBe('explorer') // manifest.agentName → agentName
578
+ expect(sub!.model).toBe('glm-5.2')
579
+ expect(sub!.status).toBe('completed')
580
+ expect(sub!.sessionFile).toBe(subPath)
581
+ expect(sub!.cleanedUp).toBe(false)
582
+ expect(sub!.rootSessionId).toBe(ROOT)
583
+ })
584
+
585
+ it('TC-u4-pfallback-identity: alive 无 manifest + identity 含 task/agent → 回退 identity,model/status undefined', async () => {
586
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
587
+ // 只写 alive session(含 identity 尾行),不写 manifest → P-fallback 路径
588
+ const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
589
+ rootSessionId: ROOT,
590
+ slug: 'fix',
591
+ task: 'fix bug',
592
+ agent: 'worker',
593
+ })
594
+
595
+ const family = await buildFamilyFromFs(ROOT, dir)
596
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
597
+
598
+ expect(sub).toBeDefined()
599
+ // P-fallback:task/agent/sessionFile 从 identity 回退
600
+ expect(sub!.task).toBe('fix bug')
601
+ expect(sub!.slug).toBe('fix')
602
+ expect(sub!.agentName).toBe('worker') // identity.data.agent → agentName
603
+ expect(sub!.sessionFile).toBe(subPath) // P-fallback sessionFile=alive meta.path
604
+ // P-fallback 核心断言:model/status 不可回退,必 undefined
605
+ expect(sub!.model).toBeUndefined()
606
+ expect(sub!.status).toBeUndefined()
607
+ expect(sub!.cleanedUp).toBe(false)
608
+ })
609
+
610
+ it('TC-u4-pfallback-no-identity: alive 无 manifest 无 identity(运行中)→ 不入 family.subagents,不抛错', async () => {
611
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
612
+ // alive 文件无 identity 尾行(运行中/异常),无 manifest
613
+ await writeAliveSubagentNoIdentity(dir, '--root-cwd--', SUB_REAL)
614
+
615
+ // 核心断言:buildFamilyFromFs 不抛错、不崩溃
616
+ const family = await buildFamilyFromFs(ROOT, dir)
617
+
618
+ // 无 manifest 无 identity → 无法确定 rootSessionId → 不入 family.subagents
619
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
620
+ expect(sub).toBeUndefined()
621
+ })
622
+
623
+ it('TC-u4-orphan-manifest: manifest 全字段 + sessionFile 指向不存在路径 → 孤儿 cleanedUp=true,富字段透传', async () => {
624
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
625
+ const ghostPath = '/nonexistent/gc-ghost.jsonl' // .jsonl 已被 GC(不写该文件)
626
+ await writeRecordManifest(dir, '--root-cwd--', 'sa-ghost-id', {
627
+ rootSessionId: ROOT,
628
+ agentName: 'worker',
629
+ sessionFile: ghostPath,
630
+ task: 'ghost task',
631
+ slug: 'ghost-slug',
632
+ model: 'gpt-4',
633
+ status: 'completed',
634
+ })
635
+
636
+ const family = await buildFamilyFromFs(ROOT, dir)
637
+ const ghost = family.subagents.find((s) => s.sessionId === 'sa-ghost-id')
638
+
639
+ expect(ghost).toBeDefined()
640
+ expect(ghost!.cleanedUp).toBe(true) // 文件 GC → cleanedUp
641
+ // 孤儿用 manifest 完整富字段
642
+ expect(ghost!.task).toBe('ghost task')
643
+ expect(ghost!.slug).toBe('ghost-slug')
644
+ expect(ghost!.agentName).toBe('worker')
645
+ expect(ghost!.model).toBe('gpt-4')
646
+ expect(ghost!.status).toBe('completed')
647
+ expect(ghost!.sessionFile).toBe(ghostPath) // GC 路径保留(不置空)
648
+ expect(ghost!.rootSessionId).toBe(ROOT)
649
+ })
650
+
651
+ it('TC-u4-recordmanifest-compat: 旧 manifest(仅 id/rootSessionId/sessionFile)→ 富字段 undefined,不抛错', async () => {
652
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
653
+ // 旧格式 manifest:无 task/slug/model/status/agentName
654
+ await writeRecordManifest(dir, '--root-cwd--', 'sa-old', {
655
+ rootSessionId: ROOT,
656
+ sessionFile: '/nonexistent/old.jsonl',
657
+ })
658
+
659
+ // listRecordManifests 兼容:返回该 manifest,富字段 undefined
660
+ const manifests = await listRecordManifests(dir)
661
+ expect(manifests).toHaveLength(1)
662
+ const m = manifests[0]
663
+ expect(m.id).toBe('sa-old')
664
+ expect(m.rootSessionId).toBe(ROOT)
665
+ expect(m.task).toBeUndefined()
666
+ expect(m.slug).toBeUndefined()
667
+ expect(m.model).toBeUndefined()
668
+ expect(m.status).toBeUndefined()
669
+ expect(m.agentName).toBeUndefined()
670
+
671
+ // buildFamilyFromFs 消费旧 manifest(孤儿路径)不抛错,富字段 undefined
672
+ const family = await buildFamilyFromFs(ROOT, dir)
673
+ const ghost = family.subagents.find((s) => s.sessionId === 'sa-old')
674
+ expect(ghost).toBeDefined()
675
+ expect(ghost!.cleanedUp).toBe(true)
676
+ expect(ghost!.task).toBeUndefined()
677
+ expect(ghost!.model).toBeUndefined()
678
+ // 旧 manifest slug 缺失 → 回退 agentName(也缺)→ 空串兜底
679
+ expect(ghost!.slug).toBe('')
680
+ })
681
+ })
682
+
683
+ // ============================================================
684
+ // U4 C4 契约:extractSessionIdFromFilename 导出(仅加 export,不改逻辑,供 w4 复用)
685
+ // ============================================================
686
+
687
+ describe('U4 extractSessionIdFromFilename 导出(C4 契约)', () => {
688
+ it('导出生效:从 <timestamp>_<sessionId>.jsonl 提取 sessionId', () => {
689
+ expect(
690
+ extractSessionIdFromFilename('2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl'),
691
+ ).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
692
+ })
693
+
694
+ it('非 uuid 特征文件名 → 空串(行为不变)', () => {
695
+ expect(extractSessionIdFromFilename('not-a-uuid.jsonl')).toBe('')
696
+ expect(extractSessionIdFromFilename('readme.txt')).toBe('')
697
+ })
698
+
699
+ it('无下划线的纯 uuid 文件名 → 返回 uuid(兼容简化场景)', () => {
700
+ expect(extractSessionIdFromFilename('019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl')).toBe(
701
+ '019fdd21-8169-7a02-8f11-eef6c9ca11cc',
702
+ )
703
+ })
704
+ })
705
+
706
+ // ============================================================
707
+ // U4 真实数据守卫(TC-u4-real-data-guard):~/.pi/agent 富字段从 manifest 正确透传
708
+ // ============================================================
709
+
710
+ const HAS_REAL_SUBAGENTS_DIR = existsSync(join(REAL_AGENT_DIR, 'subagents'))
711
+
712
+ describe.skipIf(!HAS_REAL_ROOT || !HAS_REAL_SUBAGENTS_DIR)('U4 真实数据守卫:~/.pi/agent', () => {
713
+ it('TC-u4-real-data-guard: 019fe620 subagents 富字段透传(manifest 主 task 非空率 > 80%)', async () => {
714
+ // 数据守卫:019fe635 是 019fe620 家族已知的隔代 subagent(含 task 富字段的锚点),
715
+ // 被 GC 后跳过富字段验证(manifest 数据不可复现,跳过比失败合理)。
716
+ if (!hasAnyRealSession('019fe635')) return
717
+ const family = await buildFamilyFromFs(
718
+ '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7',
719
+ REAL_AGENT_DIR,
720
+ )
721
+ // 有 subagent 才验证富字段(019fe620 确有隔代 subagent,见既有真实数据测试)
722
+ expect(family.subagents.length).toBeGreaterThan(0)
723
+
724
+ // manifest 主的 subagent(model !== undefined 近似判定,探针 manifest 20/20 有 model):
725
+ // task 非空率应 > 80%(探针 20/20 manifest 全有 task)
726
+ const manifestSourced = family.subagents.filter((s) => s.model !== undefined)
727
+ if (manifestSourced.length > 0) {
728
+ const withTask = manifestSourced.filter((s) => s.task !== undefined && s.task !== '')
729
+ expect(withTask.length / manifestSourced.length).toBeGreaterThan(0.8)
730
+ }
731
+
732
+ // P-fallback 的 subagent(model === undefined):status 必 undefined(identity 不可回退 status)
733
+ const pfallback = family.subagents.filter((s) => s.model === undefined)
734
+ expect(pfallback.every((s) => s.status === undefined)).toBe(true)
735
+
736
+ // 所有 subagent 的 sessionFile 非空(manifest.sessionFile 或 alive meta.path)
737
+ expect(family.subagents.every((s) => typeof s.sessionFile === 'string' && s.sessionFile.length > 0)).toBe(true)
738
+ }, 30000)
739
+ })