@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
@@ -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'
@@ -54,12 +60,13 @@ async function writeMainSession(
54
60
  /**
55
61
  * 写 subagent session 文件:首行 header(真实 id)+ 占位 message + 尾行 identity。
56
62
  * identity 在尾行(实测 pi 行为;subagent-identity 由 session-runner 在 session 创建后写)。
63
+ * U4 扩展:identity.task/agent 可定制(P-fallback 测试用,默认 't'/'explorer')。
57
64
  */
58
65
  async function writeSubagentSession(
59
66
  dir: string,
60
67
  slug: string,
61
- realId: string,
62
- identity: { rootSessionId: string; slug: string; dataId?: string },
68
+ realId: string,
69
+ identity: { rootSessionId: string; slug: string; dataId?: string; task?: string; agent?: string },
63
70
  ): Promise<string> {
64
71
  const sessionDir = join(dir, 'subagents', slug, 'sessions')
65
72
  await mkdir(sessionDir, { recursive: true })
@@ -79,9 +86,9 @@ async function writeSubagentSession(
79
86
  id: identity.dataId ?? `sa-${realId.slice(0, 8)}`,
80
87
  rootSessionId: identity.rootSessionId,
81
88
  slug: identity.slug,
82
- agent: 'explorer',
89
+ agent: identity.agent ?? 'explorer',
83
90
  mode: 'sync',
84
- task: 't',
91
+ task: identity.task ?? 't',
85
92
  startedAt: 1,
86
93
  },
87
94
  }),
@@ -90,6 +97,31 @@ async function writeSubagentSession(
90
97
  return path
91
98
  }
92
99
 
100
+ /**
101
+ * 写 alive subagent 文件但**无 identity 尾行**(运行中/异常,尾行是 message)。
102
+ * TC-u4-pfallback-no-identity 场景:header 有效、无 manifest、无 identity → buildFamilyFromFs 跳过。
103
+ */
104
+ async function writeAliveSubagentNoIdentity(
105
+ dir: string,
106
+ slug: string,
107
+ realId: string,
108
+ ): Promise<string> {
109
+ const sessionDir = join(dir, 'subagents', slug, 'sessions')
110
+ await mkdir(sessionDir, { recursive: true })
111
+ const path = join(sessionDir, `${realId}.jsonl`)
112
+ const lines = [
113
+ JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${slug}` }),
114
+ JSON.stringify({
115
+ type: 'message',
116
+ id: 'm1',
117
+ parentId: realId,
118
+ message: { role: 'user', content: 'still running' },
119
+ }),
120
+ ]
121
+ await writeFile(path, lines.join('\n') + '\n')
122
+ return path
123
+ }
124
+
93
125
  /** 写 wf-state 文件(每行一个快照;字符串按原样写入,可注入坏行)。返回绝对路径。 */
94
126
  async function writeWfState(dir: string, slug: string, lines: string[]): Promise<string> {
95
127
  const wfDir = join(dir, 'sessions', slug, 'workflow-state')
@@ -118,12 +150,20 @@ async function writeWfLink(
118
150
  await writeFile(sessionPath, line + '\n', { flag: 'a' })
119
151
  }
120
152
 
121
- /** 写 records manifest(孤儿源)。 */
153
+ /** 写 records manifest(孤儿源)。U4 扩展:fields 支持富字段 task/slug/model/status。 */
122
154
  async function writeRecordManifest(
123
155
  dir: string,
124
156
  slug: string,
125
157
  id: string,
126
- fields: { rootSessionId: string; agentName?: string; sessionFile: string },
158
+ fields: {
159
+ rootSessionId: string
160
+ agentName?: string
161
+ sessionFile: string
162
+ task?: string
163
+ slug?: string
164
+ model?: string
165
+ status?: string
166
+ },
127
167
  ): Promise<void> {
128
168
  const recordsDir = join(dir, 'subagents', slug, 'records')
129
169
  await mkdir(recordsDir, { recursive: true })
@@ -362,7 +402,7 @@ describe('buildFamilyFromFs - fixture', () => {
362
402
  expect(family.workflows[0].calls[0].mtime).toBe(0)
363
403
  })
364
404
 
365
- it('MF-3 回归:alive 但无 identity 的 subagent 文件(运行中)不被 manifest 收编为 cleanedUp', async () => {
405
+ it('MF-3 回归:alive 但无 identity 的 subagent(运行中)不被收编为 cleanedUp——U4 manifest 主路径建族', async () => {
366
406
  await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
367
407
  // 运行中 subagent:有效 header、无 identity 尾行(identity 完成时才写入),manifest 已存在
368
408
  const subDir = join(dir, 'subagents', '--root-cwd--', 'sessions')
@@ -379,12 +419,17 @@ describe('buildFamilyFromFs - fixture', () => {
379
419
  })
380
420
 
381
421
  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)
422
+ // U4 语义变化(TC-manifest-source):m0 alive identity完全丢弃(无数据源建族)。
423
+ // U4 manifest 主路径:manifest 命中即建族(manifest rootSessionId),不再依赖 identity
424
+ // MF-3 核心保护仍生效:不被收编为 cleanedUp(fileStats 有 realId → cleanedUp=false)。
425
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
426
+ expect(sub).toBeDefined()
427
+ expect(sub!.cleanedUp).toBe(false) // 核心:运行中不被当孤儿
428
+ expect(sub!.rootSessionId).toBe(ROOT) // manifest 主:rootSessionId 从 manifest 透传
429
+ expect(sub!.agentName).toBe('explorer') // manifest.agentName → agentName
430
+ // 无 identity 尾行 → task/model/status 取决于 manifest(本 fixture manifest 未写这些 → undefined)
431
+ expect(sub!.task).toBeUndefined()
432
+ expect(sub!.model).toBeUndefined()
388
433
  expect(family.subagents.every((s) => s.cleanedUp === false)).toBe(true)
389
434
  })
390
435
  })
@@ -428,3 +473,235 @@ describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () =
428
473
  expect(rich!.runId.startsWith('wf-')).toBe(true)
429
474
  }, 30000)
430
475
  })
476
+
477
+ // ============================================================
478
+ // w2 TC1:listRecordManifests + RecordManifest 导出(IF2/DM2,行为零变更验证)
479
+ // ============================================================
480
+
481
+ describe('listRecordManifests 导出(w2 TC1)', () => {
482
+ let dir: string
483
+
484
+ beforeEach(async () => {
485
+ dir = await makeAgentDir()
486
+ })
487
+ afterEach(async () => {
488
+ await rm(dir, { recursive: true, force: true })
489
+ })
490
+
491
+ it('导出生效:import + 调用返回 RecordManifest[],字段完整', async () => {
492
+ await writeRecordManifest(dir, '--demo-cwd--', 'sa-tc1', {
493
+ rootSessionId: 'root-1',
494
+ agentName: 'explorer',
495
+ sessionFile: '/tmp/sa-tc1.jsonl',
496
+ })
497
+ const manifests: RecordManifest[] = await listRecordManifests(dir)
498
+ expect(manifests).toHaveLength(1)
499
+ const m = manifests[0]
500
+ expect(m.id).toBe('sa-tc1')
501
+ expect(m.rootSessionId).toBe('root-1')
502
+ expect(m.agentName).toBe('explorer')
503
+ expect(m.sessionFile).toBe('/tmp/sa-tc1.jsonl')
504
+ })
505
+ })
506
+
507
+ // ============================================================
508
+ // U4 端到端:buildFamilyFromFs 富化(manifest 主 / P-fallback identity 回退 / orphan / compat)
509
+ // 验证数据流重组:manifest 索引命中透全字段,未命中读尾行 identity 回退,孤儿 manifest 富字段+cleanedUp
510
+ // ============================================================
511
+
512
+ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
513
+ let dir: string
514
+
515
+ beforeEach(async () => {
516
+ dir = await makeAgentDir()
517
+ })
518
+ afterEach(async () => {
519
+ await rm(dir, { recursive: true, force: true })
520
+ })
521
+
522
+ it('TC-u4-manifest-enrich: alive + manifest 全字段 → SubagentRef 富字段全透传', async () => {
523
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
524
+ const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
525
+ rootSessionId: ROOT,
526
+ slug: 'identity-slug', // identity.slug,manifest 主时被 manifest.slug 覆盖
527
+ task: 'identity-task', // identity.task,manifest 主时被 manifest.task 覆盖
528
+ agent: 'worker', // identity.agent,manifest 主时被 manifest.agentName 覆盖
529
+ })
530
+ // manifest 全字段(命中 meta.path 索引 → 走 manifest 主路径,覆盖 identity 值)
531
+ await writeRecordManifest(dir, '--root-cwd--', `sa-${SUB_REAL}`, {
532
+ rootSessionId: ROOT,
533
+ agentName: 'explorer',
534
+ sessionFile: subPath,
535
+ task: '调研 codex',
536
+ slug: 'codex-ask-user-research',
537
+ model: 'glm-5.2',
538
+ status: 'completed',
539
+ })
540
+
541
+ const family = await buildFamilyFromFs(ROOT, dir)
542
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
543
+
544
+ expect(sub).toBeDefined()
545
+ // manifest 主:富字段从 manifest 透传(覆盖 identity 的值)
546
+ expect(sub!.task).toBe('调研 codex')
547
+ expect(sub!.slug).toBe('codex-ask-user-research')
548
+ expect(sub!.agentName).toBe('explorer') // manifest.agentName → agentName
549
+ expect(sub!.model).toBe('glm-5.2')
550
+ expect(sub!.status).toBe('completed')
551
+ expect(sub!.sessionFile).toBe(subPath)
552
+ expect(sub!.cleanedUp).toBe(false)
553
+ expect(sub!.rootSessionId).toBe(ROOT)
554
+ })
555
+
556
+ it('TC-u4-pfallback-identity: alive 无 manifest + identity 含 task/agent → 回退 identity,model/status undefined', async () => {
557
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
558
+ // 只写 alive session(含 identity 尾行),不写 manifest → P-fallback 路径
559
+ const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
560
+ rootSessionId: ROOT,
561
+ slug: 'fix',
562
+ task: 'fix bug',
563
+ agent: 'worker',
564
+ })
565
+
566
+ const family = await buildFamilyFromFs(ROOT, dir)
567
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
568
+
569
+ expect(sub).toBeDefined()
570
+ // P-fallback:task/agent/sessionFile 从 identity 回退
571
+ expect(sub!.task).toBe('fix bug')
572
+ expect(sub!.slug).toBe('fix')
573
+ expect(sub!.agentName).toBe('worker') // identity.data.agent → agentName
574
+ expect(sub!.sessionFile).toBe(subPath) // P-fallback sessionFile=alive meta.path
575
+ // P-fallback 核心断言:model/status 不可回退,必 undefined
576
+ expect(sub!.model).toBeUndefined()
577
+ expect(sub!.status).toBeUndefined()
578
+ expect(sub!.cleanedUp).toBe(false)
579
+ })
580
+
581
+ it('TC-u4-pfallback-no-identity: alive 无 manifest 无 identity(运行中)→ 不入 family.subagents,不抛错', async () => {
582
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
583
+ // alive 文件无 identity 尾行(运行中/异常),无 manifest
584
+ await writeAliveSubagentNoIdentity(dir, '--root-cwd--', SUB_REAL)
585
+
586
+ // 核心断言:buildFamilyFromFs 不抛错、不崩溃
587
+ const family = await buildFamilyFromFs(ROOT, dir)
588
+
589
+ // 无 manifest 无 identity → 无法确定 rootSessionId → 不入 family.subagents
590
+ const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
591
+ expect(sub).toBeUndefined()
592
+ })
593
+
594
+ it('TC-u4-orphan-manifest: manifest 全字段 + sessionFile 指向不存在路径 → 孤儿 cleanedUp=true,富字段透传', async () => {
595
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
596
+ const ghostPath = '/nonexistent/gc-ghost.jsonl' // .jsonl 已被 GC(不写该文件)
597
+ await writeRecordManifest(dir, '--root-cwd--', 'sa-ghost-id', {
598
+ rootSessionId: ROOT,
599
+ agentName: 'worker',
600
+ sessionFile: ghostPath,
601
+ task: 'ghost task',
602
+ slug: 'ghost-slug',
603
+ model: 'gpt-4',
604
+ status: 'completed',
605
+ })
606
+
607
+ const family = await buildFamilyFromFs(ROOT, dir)
608
+ const ghost = family.subagents.find((s) => s.sessionId === 'sa-ghost-id')
609
+
610
+ expect(ghost).toBeDefined()
611
+ expect(ghost!.cleanedUp).toBe(true) // 文件 GC → cleanedUp
612
+ // 孤儿用 manifest 完整富字段
613
+ expect(ghost!.task).toBe('ghost task')
614
+ expect(ghost!.slug).toBe('ghost-slug')
615
+ expect(ghost!.agentName).toBe('worker')
616
+ expect(ghost!.model).toBe('gpt-4')
617
+ expect(ghost!.status).toBe('completed')
618
+ expect(ghost!.sessionFile).toBe(ghostPath) // GC 路径保留(不置空)
619
+ expect(ghost!.rootSessionId).toBe(ROOT)
620
+ })
621
+
622
+ it('TC-u4-recordmanifest-compat: 旧 manifest(仅 id/rootSessionId/sessionFile)→ 富字段 undefined,不抛错', async () => {
623
+ await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
624
+ // 旧格式 manifest:无 task/slug/model/status/agentName
625
+ await writeRecordManifest(dir, '--root-cwd--', 'sa-old', {
626
+ rootSessionId: ROOT,
627
+ sessionFile: '/nonexistent/old.jsonl',
628
+ })
629
+
630
+ // listRecordManifests 兼容:返回该 manifest,富字段 undefined
631
+ const manifests = await listRecordManifests(dir)
632
+ expect(manifests).toHaveLength(1)
633
+ const m = manifests[0]
634
+ expect(m.id).toBe('sa-old')
635
+ expect(m.rootSessionId).toBe(ROOT)
636
+ expect(m.task).toBeUndefined()
637
+ expect(m.slug).toBeUndefined()
638
+ expect(m.model).toBeUndefined()
639
+ expect(m.status).toBeUndefined()
640
+ expect(m.agentName).toBeUndefined()
641
+
642
+ // buildFamilyFromFs 消费旧 manifest(孤儿路径)不抛错,富字段 undefined
643
+ const family = await buildFamilyFromFs(ROOT, dir)
644
+ const ghost = family.subagents.find((s) => s.sessionId === 'sa-old')
645
+ expect(ghost).toBeDefined()
646
+ expect(ghost!.cleanedUp).toBe(true)
647
+ expect(ghost!.task).toBeUndefined()
648
+ expect(ghost!.model).toBeUndefined()
649
+ // 旧 manifest slug 缺失 → 回退 agentName(也缺)→ 空串兜底
650
+ expect(ghost!.slug).toBe('')
651
+ })
652
+ })
653
+
654
+ // ============================================================
655
+ // U4 C4 契约:extractSessionIdFromFilename 导出(仅加 export,不改逻辑,供 w4 复用)
656
+ // ============================================================
657
+
658
+ describe('U4 extractSessionIdFromFilename 导出(C4 契约)', () => {
659
+ it('导出生效:从 <timestamp>_<sessionId>.jsonl 提取 sessionId', () => {
660
+ expect(
661
+ extractSessionIdFromFilename('2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl'),
662
+ ).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
663
+ })
664
+
665
+ it('非 uuid 特征文件名 → 空串(行为不变)', () => {
666
+ expect(extractSessionIdFromFilename('not-a-uuid.jsonl')).toBe('')
667
+ expect(extractSessionIdFromFilename('readme.txt')).toBe('')
668
+ })
669
+
670
+ it('无下划线的纯 uuid 文件名 → 返回 uuid(兼容简化场景)', () => {
671
+ expect(extractSessionIdFromFilename('019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl')).toBe(
672
+ '019fdd21-8169-7a02-8f11-eef6c9ca11cc',
673
+ )
674
+ })
675
+ })
676
+
677
+ // ============================================================
678
+ // U4 真实数据守卫(TC-u4-real-data-guard):~/.pi/agent 富字段从 manifest 正确透传
679
+ // ============================================================
680
+
681
+ const HAS_REAL_SUBAGENTS_DIR = existsSync(join(REAL_AGENT_DIR, 'subagents'))
682
+
683
+ describe.skipIf(!HAS_REAL_ROOT || !HAS_REAL_SUBAGENTS_DIR)('U4 真实数据守卫:~/.pi/agent', () => {
684
+ it('TC-u4-real-data-guard: 019fe620 subagents 富字段透传(manifest 主 task 非空率 > 80%)', async () => {
685
+ const family = await buildFamilyFromFs(
686
+ '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7',
687
+ REAL_AGENT_DIR,
688
+ )
689
+ // 有 subagent 才验证富字段(019fe620 确有隔代 subagent,见既有真实数据测试)
690
+ expect(family.subagents.length).toBeGreaterThan(0)
691
+
692
+ // manifest 主的 subagent(model !== undefined 近似判定,探针 manifest 20/20 有 model):
693
+ // task 非空率应 > 80%(探针 20/20 manifest 全有 task)
694
+ const manifestSourced = family.subagents.filter((s) => s.model !== undefined)
695
+ if (manifestSourced.length > 0) {
696
+ const withTask = manifestSourced.filter((s) => s.task !== undefined && s.task !== '')
697
+ expect(withTask.length / manifestSourced.length).toBeGreaterThan(0.8)
698
+ }
699
+
700
+ // P-fallback 的 subagent(model === undefined):status 必 undefined(identity 不可回退 status)
701
+ const pfallback = family.subagents.filter((s) => s.model === undefined)
702
+ expect(pfallback.every((s) => s.status === undefined)).toBe(true)
703
+
704
+ // 所有 subagent 的 sessionFile 非空(manifest.sessionFile 或 alive meta.path)
705
+ expect(family.subagents.every((s) => typeof s.sessionFile === 'string' && s.sessionFile.length > 0)).toBe(true)
706
+ }, 30000)
707
+ })