@zhushanwen/pi-session-reader 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -15,10 +15,13 @@
15
15
  "keywords": [
16
16
  "pi-package"
17
17
  ],
18
+ "dependencies": {
19
+ "@zhushanwen/pi-ext-guards": "0.4.0"
20
+ },
18
21
  "devDependencies": {
19
22
  "@vitest/coverage-v8": "^4.1.9",
20
23
  "vitest": "^4.1.8",
21
- "@zhushanwen/subagent-core": "0.7.1"
24
+ "@zhushanwen/subagent-core": "0.10.1"
22
25
  },
23
26
  "files": [
24
27
  "index.ts",
@@ -12,12 +12,12 @@ import {
12
12
  SubagentService,
13
13
  type SubagentRecord,
14
14
  } from '@zhushanwen/subagent-core'
15
- import { ManifestStore } from '@zhushanwen/subagent-core/execution/manifest-store.ts'
16
- import type { ModelRegistryLike } from '@zhushanwen/subagent-core/execution/model-resolver.ts'
15
+ import { ManifestStore } from '@zhushanwen/subagent-core/execution/persistence/manifest-store.ts'
16
+ import type { ModelRegistryLike } from '@zhushanwen/subagent-core/execution/assembly/model-resolver.ts'
17
17
  import {
18
18
  getSubagentRecordsDir,
19
19
  getSubagentSessionDir,
20
- } from '@zhushanwen/subagent-core/execution/path-encoding.ts'
20
+ } from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
21
21
 
22
22
  /**
23
23
  * W4 跨包集成测试(subagent-sync-collect v2 impl-plan W4 验收条款①,设计 §5 W4:
@@ -213,13 +213,14 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
213
213
  return childFile
214
214
  }
215
215
 
216
- /** 种「崩溃前主文件末条序列」:register(running+sync)→ 轮终(running+resumable+
217
- * result 全文 + sessionFile)——成功成员崩溃时的真实末条形态。 */
216
+ /** 种「崩溃前主文件末条序列」:register(running+sync)→ 轮终(idle + result
217
+ * 全文 + sessionFile)——[U4/D4] 翻边后成功成员崩溃时的真实末条形态(U5 前的
218
+ * running+resumable 桥接形态已随字段退役消亡)。 */
218
219
  function seedRoundTerminalEntries(m: MemberSeed, childFile: string): void {
219
220
  const store = makeSeedStore()
220
221
  store.reportSubagentRecord(memberRecord({ id: m.id, sessionFile: childFile, model: m.model }))
221
222
  store.reportSubagentRecord(
222
- memberRecord({ id: m.id, sessionFile: childFile, model: m.model, resumable: true, result: m.resultText }),
223
+ memberRecord({ id: m.id, sessionFile: childFile, model: m.model, status: 'idle', result: m.resultText }),
223
224
  )
224
225
  }
225
226
 
@@ -265,7 +266,9 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
265
266
  const childFiles = await driveSyncBatchRecovery([m])
266
267
 
267
268
  // manifest 内容断言(W3 appendBatchFinalizedEntry 投影路径的产物,非手工预写):
268
- // 覆写后 closed 重建快照 → status 如实投影 "closed";sessionFile 来自 W1 投影扩展。
269
+ // sessionFile 来自 W1 投影扩展。[U2/U3 两态桥接] E1 落标成员经恢复链覆写后处于
270
+ // idle(重建单规则)/running 形态、无旧终态遗留位 → legacy status 如实投影
271
+ // "running"(batchManifestRecord 单点派生,session-reader 视角的活跃成员)。
269
272
  const manifest = JSON.parse(
270
273
  fs.readFileSync(path.join(getSubagentRecordsDir(agentDir, agentDir), 'sa-cross-1.json'), 'utf-8'),
271
274
  ) as Record<string, unknown>
@@ -273,12 +276,12 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
273
276
  id: 'sa-cross-1',
274
277
  rootSessionId: ROOT_SESSION,
275
278
  agentName: '/agents/worker.md',
276
- status: 'closed',
279
+ status: 'running',
277
280
  sessionFile: childFiles.get('sa-cross-1'),
278
281
  })
279
282
 
280
283
  // session-reader 侧:sa- id 反查(v1 此处报「无匹配 record」——manifest 惰性不落盘)
281
- const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, agentDir)
284
+ const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, { agentDir })
282
285
  // 同源语义:跨 assistant message "\n\n" join、thinking 块排除——与 record.result 逐字节一致
283
286
  expect(r.content[0]?.text).toBe(resultText)
284
287
  const d = r.details as {
@@ -302,7 +305,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
302
305
  ]
303
306
  await driveSyncBatchRecovery(members)
304
307
 
305
- const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, agentDir)
308
+ const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, { agentDir })
306
309
  const text = r.content[0]?.text ?? ''
307
310
  expect(text).toContain('[1/2] sa-cross-a')
308
311
  expect(text).toContain('[2/2] sa-cross-b')
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
- import { execSync } from 'node:child_process'
4
3
  import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
5
4
  import { join } from 'node:path'
6
5
  import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } from '../core/execution-tree.js'
@@ -18,7 +17,11 @@ import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } f
18
17
  * - TC-m3b-cycle-detection:workflow 指针环(A→B→A),visited Set 防环
19
18
  * - TC-m3b-single-node:单节点树(无后代,ES5)
20
19
  * - TC-m3b-source-priority:parentRecordId 三级数据源优先级(manifest>identity>flat,DM4)
21
- * - TC-m3b-real-data-guard:本机真实语料解析不抛错、root 定位正确(skipIf CI 无数据)
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 具体值
22
25
  */
23
26
 
24
27
  // ---- fixture 常量(uuid 特征,互不为子串,满足 extractSessionIdFromFilename)----
@@ -32,20 +35,6 @@ const X_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000006'
32
35
  const RUN_A = 'wf-1786121304924-runA'
33
36
  const RUN_B = 'wf-1786121304924-runB'
34
37
 
35
- /** 真实 pi agent 目录(本机),用于集成测试守卫。 */
36
- const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
37
- const HAS_REAL = (() => {
38
- try {
39
- return (
40
- execSync(`find ${REAL_AGENT_DIR}/sessions -maxdepth 1 -type d 2>/dev/null | head -1`, {
41
- encoding: 'utf8',
42
- }).trim().length > 0
43
- )
44
- } catch {
45
- return false
46
- }
47
- })()
48
-
49
38
  // ---- fixture helpers(自包含,拓扑同 subagents.test.ts)----
50
39
 
51
40
  async function makeAgentDir(): Promise<string> {
@@ -64,6 +53,7 @@ async function writeMainSession(dir: string, slug: string, id: string): Promise<
64
53
  /**
65
54
  * 写 subagent session 文件:首行 header(真实 id)+ 占位 message + 尾行 identity。
66
55
  * identity 尾行含 rootSessionId/slug/agent,可选 parentRecordId(数据源 ② 测试用)。
56
+ * fileName 可选:默认 `<realId>.jsonl`;真实形态守卫用例传时间戳前缀名(<ts>_<id>.jsonl)。
67
57
  */
68
58
  async function writeSubagentSession(
69
59
  dir: string,
@@ -76,10 +66,11 @@ async function writeSubagentSession(
76
66
  parentRecordId?: string
77
67
  task?: string
78
68
  },
69
+ fileName: string = `${realId}.jsonl`,
79
70
  ): Promise<string> {
80
71
  const sessionDir = join(dir, 'subagents', slug, 'sessions')
81
72
  await mkdir(sessionDir, { recursive: true })
82
- const path = join(sessionDir, `${realId}.jsonl`)
73
+ const path = join(sessionDir, fileName)
83
74
  const data: Record<string, unknown> = {
84
75
  id: `sa-${realId.slice(0, 8)}`,
85
76
  rootSessionId: identity.rootSessionId,
@@ -688,23 +679,140 @@ describe('buildExecutionTree - fixture', () => {
688
679
  })
689
680
 
690
681
  // ============================================================
691
- // 真实数据守卫(CI 无本机 ~/.pi/agent → skip)
682
+ // 真实形态数据守卫(合成 fixture,2026-09 测试债修复)
692
683
  // ============================================================
684
+ //
685
+ // 原用例直读本机 ~/.pi/agent(execSync 探测 + findRealTreeRoot 扫真实 records 动态发现
686
+ // 家族根,skipIf CI 无数据):真实数据演化后确定性红(impl-plan 台账登记根因),且触碰
687
+ // 真实数据目录违反 TEST-STRATEGY 红线。数据面合成化——mkdtemp 临时目录仿真真实 agentDir
688
+ // 布局,guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域)原样保留。
693
689
 
694
- describe.skipIf(!HAS_REAL)('buildExecutionTree - 真实数据守卫', () => {
695
- it('TC-m3b-real-data-guard:本机真实语料解析不抛错,root 定位正确', async () => {
696
- // 取一个真实存在的 main session(FAM 是 fork 家族根)
697
- const FAM = '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7'
698
- const tree = await buildExecutionTree(FAM, REAL_AGENT_DIR)
699
- // [HISTORICAL] 只断言与宿主数据纪元无关的不变量:sourceMode / totalNodes 取决于 FAM
700
- // 家族在 subagents/**/records 下的 manifest 存量,会被 pi GC 清空(2026-09-11 实测该
701
- // 家族 records/ 已空 → related=[] → sourceMode='precise'、totalNodes=1,原 flat-fallback
702
- // + totalNodes>1 断言随数据演化假红)。flat-fallback 语义由 fixture 用例
703
- // TC-m3b-flat-fallback 确定性覆盖,不依赖本机可变语料。
690
+ describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
691
+ let dir: string
692
+
693
+ beforeEach(async () => {
694
+ dir = await makeAgentDir()
695
+ })
696
+ afterEach(async () => {
697
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
698
+ })
699
+
700
+ it('TC-m3b-real-data-guard:真实形态布局(时间戳文件名/新旧机制混合/孤儿 manifest/坏 manifest/外来家族)解析不抛错,树非空且结构自洽', async () => {
701
+ // 仿真真实 agentDir 数据形态(原直读本机 ~/.pi/agent,合成化后确定性可重跑):
702
+ // - session 文件名带时间戳前缀(<ISO 时间戳>_<sessionId>.jsonl,真实 pi 落盘形态)
703
+ // - 混合新旧机制:A 顶层(无 parentRecordId)→ B 嵌套(manifest.parentRecordId 精确链);
704
+ // C 旧机制顶层(manifest/identity 均无 parentRecordId → flat 挂 main)
705
+ // - 孤儿 manifest D:sessionFile 已被 30 天 GC、manifest 残留(真实数据常态)
706
+ // - 坏 manifest(JSON 损坏)不中断扫描(ES4)
707
+ // - 外来家族 X(同 agentDir 其他 main 的 record)不应泄漏进目标树
708
+ const CWD = '--guard-cwd--'
709
+ const TS = '2026-09-11T10-00-00-000Z'
710
+ const OTHER_MAIN = '0aaaaaaa-bbbb-7ccc-dddd-000000000009'
711
+ const prefixed = (id: string): string => `${TS}_${id}.jsonl`
712
+
713
+ await writeMainSession(dir, CWD, MAIN)
714
+ // A:顶层 subagent(identity 尾行走数据源 ② 读路径,无 parentRecordId → 顶层挂 main)
715
+ const saA = await writeSubagentSession(
716
+ dir,
717
+ CWD,
718
+ A_REAL,
719
+ { rootSessionId: MAIN, slug: 'guard-a' },
720
+ prefixed(A_REAL),
721
+ )
722
+ await writeRecordManifest(dir, CWD, 'rec-ga', {
723
+ rootSessionId: MAIN,
724
+ agentName: 'explorer',
725
+ sessionFile: saA,
726
+ slug: 'guard-a',
727
+ status: 'completed',
728
+ })
729
+ // B:A 的嵌套后代(manifest.parentRecordId 精确链,新机制)
730
+ const saB = await writeSubagentSession(
731
+ dir,
732
+ CWD,
733
+ B_REAL,
734
+ { rootSessionId: MAIN, slug: 'guard-b' },
735
+ prefixed(B_REAL),
736
+ )
737
+ await writeRecordManifest(dir, CWD, 'rec-gb', {
738
+ rootSessionId: MAIN,
739
+ agentName: 'worker',
740
+ sessionFile: saB,
741
+ slug: 'guard-b',
742
+ status: 'completed',
743
+ parentRecordId: 'rec-ga',
744
+ })
745
+ // C:旧机制顶层 subagent(manifest/identity 均无 parentRecordId → flat 挂 main)
746
+ const saC = await writeSubagentSession(
747
+ dir,
748
+ CWD,
749
+ C_REAL,
750
+ { rootSessionId: MAIN, slug: 'guard-c' },
751
+ prefixed(C_REAL),
752
+ )
753
+ await writeRecordManifest(dir, CWD, 'rec-gc', {
754
+ rootSessionId: MAIN,
755
+ agentName: 'explorer',
756
+ sessionFile: saC,
757
+ slug: 'guard-c',
758
+ status: 'completed',
759
+ })
760
+ // D:孤儿 manifest(sessionFile 指向已被 GC 的不存在文件——manifest 残留形态)
761
+ await writeRecordManifest(dir, CWD, 'rec-gd', {
762
+ rootSessionId: MAIN,
763
+ agentName: 'worker',
764
+ sessionFile: join(dir, 'subagents', CWD, 'sessions', prefixed(D_REAL)),
765
+ status: 'completed',
766
+ })
767
+ // 坏 manifest(JSON 损坏,listRecordManifests 容错跳过不中断)
768
+ await mkdir(join(dir, 'subagents', CWD, 'records'), { recursive: true })
769
+ await writeFile(join(dir, 'subagents', CWD, 'records', 'broken.json'), '{oops')
770
+ // 外来家族 X:同 agentDir 内其他 main(OTHER_MAIN)的 subagent,不应泄漏进 MAIN 的树
771
+ const saX = await writeSubagentSession(
772
+ dir,
773
+ CWD,
774
+ X_REAL,
775
+ { rootSessionId: OTHER_MAIN, slug: 'foreign-x' },
776
+ prefixed(X_REAL),
777
+ )
778
+ await writeRecordManifest(dir, CWD, 'rec-gx', {
779
+ rootSessionId: OTHER_MAIN,
780
+ agentName: 'explorer',
781
+ sessionFile: saX,
782
+ status: 'completed',
783
+ })
784
+
785
+ const tree = await buildExecutionTree(MAIN, dir)
786
+
787
+ // 契约值域:sourceMode 只能是两种精度模式之一(record 含 parentRecordId → precise,
788
+ // 全无 → flat-fallback),不锁具体值
789
+ expect(['precise', 'flat-fallback']).toContain(tree.sourceMode)
790
+ // 家族树非空:main + 顶层 A/C/D + A 的嵌套后代 B = 5;外来 X 不泄漏(合成数据确定性
791
+ // 可数,精确计数即非泄漏守卫)
792
+ expect(tree.totalNodes).toBe(5)
704
793
  expect(tree.root.type).toBe('main')
705
- expect(tree.root.sessionId).toBe(FAM)
706
- expect(tree.totalNodes).toBeGreaterThanOrEqual(1)
707
- }, 60000)
794
+ expect(tree.root.sessionId).toBe(MAIN)
795
+ expect(findNode(tree.root, 'subagent', X_REAL)).toBeUndefined()
796
+ // 孤儿 D 仍入树(sessionFile 残留路径,不因文件缺失抛错/丢弃 record——原真实数据守卫
797
+ // 独有覆盖面,合成化后保留)
798
+ const nodeD = findNode(tree.root, 'subagent', D_REAL)
799
+ expect(nodeD).toBeDefined()
800
+ expect(nodeD!.sessionFile).toBe(join(dir, 'subagents', CWD, 'sessions', prefixed(D_REAL)))
801
+ // 树结构自洽(ExecutionTreeNode 契约,与具体数据形态无关):DFS 实际节点数 ===
802
+ // totalNodes;子节点 depth = 父 depth + 1;全树 rootSessionId 共享同一顶层 main
803
+ let count = 0
804
+ const walk = (node: ExecutionTreeNode): void => {
805
+ count++
806
+ expect(node.rootSessionId).toBe(tree.root.rootSessionId)
807
+ for (const c of node.children) {
808
+ expect(c.depth).toBe(node.depth + 1)
809
+ walk(c)
810
+ }
811
+ }
812
+ walk(tree.root)
813
+ expect(count).toBe(tree.totalNodes)
814
+ // 不抛错(已隐含:到这行说明成功)
815
+ })
708
816
  })
709
817
 
710
818
  // ============================================================
@@ -107,13 +107,6 @@ describe('buildFamilyIndex', () => {
107
107
  expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
108
108
  })
109
109
 
110
- it('parentSession 直接是 sessionId 也兼容(简化 fixture)', () => {
111
- const headers = [header('aaa'), header('bbb', { parentSession: 'aaa' })]
112
- const index = buildFamilyIndex(headers, [], new Map())
113
-
114
- expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
115
- })
116
-
117
110
  it('parentSession 反查不到父 → 该 entry 不进 childrenOf(不报错)', () => {
118
111
  const headers = [header('bbb', { parentSession: '/missing.jsonl' })]
119
112
  const index = buildFamilyIndex(headers, [], new Map())
@@ -133,7 +133,7 @@ async function makeSubagentSession(
133
133
  describe('findSessions', () => {
134
134
  let agentDir: string
135
135
  let slugDir: string
136
- /** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,roots.listSubagentSessions 扫描路径) */
136
+ /** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,resolveSessionRoots [subagent] 根扫描路径) */
137
137
  let saDir: string
138
138
 
139
139
  let recordsDir: string
@@ -705,8 +705,8 @@ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
705
705
  await mkdir(legacyDir, { recursive: true })
706
706
  const provider = vi.fn(async (dir: string) =>
707
707
  dir === flatMain
708
- ? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: 1000 }]
709
- : [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: 2000 }],
708
+ ? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: new Date(1000) }]
709
+ : [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: new Date(2000) }],
710
710
  )
711
711
  const { matches } = await findSessions('新标题', agentDir, { metadataProvider: provider })
712
712
  expect(provider).toHaveBeenCalledTimes(2) // 两个平铺存在的根各一次
@@ -753,8 +753,8 @@ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
753
753
  const provider = vi.fn(async (dir: string) =>
754
754
  dir === flatMain
755
755
  ? [
756
- { path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: (base + 300) * 1000 },
757
- { path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: (base + 200) * 1000 },
756
+ { path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: new Date((base + 300) * 1000) },
757
+ { path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: new Date((base + 200) * 1000) },
758
758
  ]
759
759
  : [],
760
760
  )
@@ -22,7 +22,7 @@ export const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
22
22
  * 这类用例遍历本机 ~/.pi/agent 全量会话语料(实测 4600+ 文件),空载单用例已需 6-10s;
23
23
  * 38 包并行全量测试时 CPU/IO 争抢可降速 3-4 倍,30s 标定必假红。90s ≈ 空载 9 倍余量,
24
24
  * 仍能兜住真正的挂死。仅用于遍历全量语料的用例(findSessions / buildFamilyFromFs /
25
- * listMainSessions 等);读已知单文件的用例不需要放宽。
25
+ * resolveSessionRoots 等);读已知单文件的用例不需要放宽。
26
26
  */
27
27
  export const REAL_DATA_TIMEOUT_MS = 90_000
28
28
 
@@ -0,0 +1,177 @@
1
+ // 跨包集成([U4c / G4-S5③] record 持久化收敛 P4c 验收锚点):
2
+ // subagent-core 重建/终态写面产出的 manifest → session-reader identity 投影不降级。
3
+ //
4
+ // 断言链(设计 subagent-record-persistence-consolidation D5 前向兼容判定 + S5③):
5
+ // 1. manifest 被人为删除(S5 缓存可丢场景)→ subagent-core RecordStore.rebuildIndexes
6
+ // 全量重建 → session-reader listRecordManifests 重新可见;
7
+ // 2. 重建窗口前后,session-reader identity 投影的输入字段集(rootSessionId/slug/
8
+ // task/agentName/model/status/sessionFile/parentRecordId)逐字段一致——双写过渡
9
+ // 字段(executionStatus)在场且不被本包消费(旧 status 三态投影继续生效);
10
+ // 3. 重建源 = `.state` 权威(closedReason 从终态位派生,旧三态 cancelled 形态
11
+ // 保真——buildRecord 分支 1 → derivedManifestRecord 派生)。
12
+ //
13
+ // 形态同 cross-package-subagent-core.test.ts:subagent-core 真实 tmpdir 产出磁盘
14
+ // 状态,本包零 mock 读同一 tmpdir;不触碰真实数据目录(红线)。
15
+
16
+ import * as fs from 'node:fs'
17
+ import * as os from 'node:os'
18
+ import * as path from 'node:path'
19
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
20
+
21
+ import { RecordStore } from '@zhushanwen/subagent-core'
22
+ // barrel 外符号按包名 .ts 深路径消费(vitest alias 重写,同 cross-package 先例)
23
+ import { createRecord } from '@zhushanwen/subagent-core/execution/persistence/execution-record.ts'
24
+ import {
25
+ getSubagentRecordsDir,
26
+ getSubagentSessionDir,
27
+ } from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
28
+
29
+ import { listRecordManifests, type RecordManifest } from '../discovery/subagents.js'
30
+
31
+ const ROOT_SESSION = 'root-dual-write'
32
+
33
+ /** session-reader identity 投影(manifestIdentityData)消费的字段集——重建窗口
34
+ * 前后必须逐字段一致(降级 = 任一字段缺失/漂移)。 */
35
+ function identityView(m: RecordManifest) {
36
+ return {
37
+ id: m.id,
38
+ rootSessionId: m.rootSessionId,
39
+ agentName: m.agentName,
40
+ task: m.task,
41
+ slug: m.slug,
42
+ model: m.model,
43
+ status: m.status,
44
+ sessionFile: m.sessionFile,
45
+ parentRecordId: m.parentRecordId,
46
+ }
47
+ }
48
+
49
+ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → session-reader 投影不降级', () => {
50
+ let agentDir: string
51
+ let sessionsDir: string
52
+ let recordsDir: string
53
+ let childFile: string
54
+
55
+ beforeEach(() => {
56
+ agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-rebuild-dualwrite-'))
57
+ sessionsDir = getSubagentSessionDir(agentDir, agentDir)
58
+ recordsDir = getSubagentRecordsDir(agentDir, agentDir)
59
+ fs.mkdirSync(sessionsDir, { recursive: true })
60
+ fs.mkdirSync(recordsDir, { recursive: true })
61
+
62
+ // 子 session 文件:session header + model_change(light 重建的 model 源——
63
+ // identity 前途经 entry best-effort 提取)+ identity entry(session-reader
64
+ // 步骤 2 首行 header 探测 + manifest 主路径的数据锚)
65
+ childFile = path.join(sessionsDir, '20260912T000000_sa-dw.jsonl')
66
+ const ts = new Date(1000).toISOString()
67
+ fs.writeFileSync(
68
+ childFile,
69
+ [
70
+ JSON.stringify({ type: 'session', version: 3, id: 'sess-sa-dw', timestamp: ts, cwd: agentDir }),
71
+ JSON.stringify({ type: 'model_change', id: 'mc-1', parentId: null, timestamp: ts, provider: 'prov', modelId: 'm1' }),
72
+ JSON.stringify({
73
+ type: 'custom',
74
+ id: 'id-1',
75
+ parentId: null,
76
+ timestamp: ts,
77
+ customType: 'subagent-identity',
78
+ data: {
79
+ id: 'sa-dw',
80
+ agent: 'worker',
81
+ mode: 'background',
82
+ task: 'dual write task',
83
+ slug: 'dual',
84
+ startedAt: 1000,
85
+ rootSessionId: ROOT_SESSION,
86
+ parentRecordId: 'sa-parent',
87
+ depth: 1,
88
+ },
89
+ }),
90
+ ].join('\n') + '\n',
91
+ 'utf-8',
92
+ )
93
+ })
94
+
95
+ afterEach(() => {
96
+ fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
97
+ })
98
+
99
+ it('S5③: manifest 删除 → rebuildIndexes 重建 → identity 投影字段集前后一致(双写字段在场)', async () => {
100
+ // ── 初始 manifest:subagent-core 终态写面(markFinalized)真实产出 ──
101
+ const record = createRecord('sa-dw', {
102
+ agent: 'worker',
103
+ model: 'prov/m1',
104
+ mode: 'background',
105
+ task: 'dual write task',
106
+ slug: 'dual',
107
+ startedAt: 1000,
108
+ rootSessionId: ROOT_SESSION,
109
+ parentRecordId: 'sa-parent',
110
+ depth: 1,
111
+ chatMode: false,
112
+ controller: new AbortController(),
113
+ })
114
+ Object.assign(record, {
115
+ sessionFile: childFile,
116
+ status: 'closed' as const,
117
+ closedReason: 'gc' as const,
118
+ endedAt: 2000,
119
+ })
120
+ const writer = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
121
+ expect(writer.markFinalized(record, 'gc')).toBe(true)
122
+ writer.dispose()
123
+
124
+ const before = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
125
+ expect(before).toBeDefined()
126
+ const viewBefore = identityView(before!)
127
+
128
+ // 双写过渡字段在写面就位(本包不消费,但前向兼容锚要求其在场)。
129
+ // [U2 两态] executionStatus 是内部权威词汇(running|idle 两态,终态概念删除)
130
+ // → 终态化后恒 'idle';status 旧三态是 session-reader 直读投影(永久保留)→ 'closed'。
131
+ const rawBefore = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
132
+ expect(rawBefore.executionStatus).toBe('idle')
133
+ expect(rawBefore.status).toBe('closed')
134
+
135
+ // ── S5 场景:人为删除全部 manifest(缓存可丢)→ 全量重建 ──
136
+ for (const f of fs.readdirSync(recordsDir)) fs.rmSync(path.join(recordsDir, f))
137
+ expect((await listRecordManifests(agentDir)).filter((m) => m.id === 'sa-dw')).toHaveLength(0)
138
+
139
+ const rebuilder = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
140
+ expect(rebuilder.rebuildIndexes()).toBe(1)
141
+ rebuilder.dispose()
142
+
143
+ // ── session-reader 视角:重建后 identity 投影不降级 ──
144
+ const after = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
145
+ expect(after).toBeDefined()
146
+ expect(identityView(after!)).toEqual(viewBefore)
147
+
148
+ // 重建源 = `.state` 权威:closedReason 从终态位派生回 manifest(旧三态 closed)
149
+ const rawAfter = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
150
+ expect(rawAfter.status).toBe('closed')
151
+ expect(rawAfter.executionStatus).toBe('idle')
152
+ expect(rawAfter.closedReason).toBe('gc')
153
+ })
154
+
155
+ it('惰性通道:查询面(collectRecords)补建的 manifest 同样可被本包读取投影', async () => {
156
+ // 不经终态写面(无 manifest 初始形态):磁盘 record 只有 identity + 无 sidecar
157
+ // → 查询触发惰性补建。
158
+ const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
159
+ expect(store.collectRecords(10).map((r) => r.id)).toContain('sa-dw')
160
+ store.dispose()
161
+
162
+ const manifests = await listRecordManifests(agentDir)
163
+ const found = manifests.find((m) => m.id === 'sa-dw')
164
+ expect(found).toBeDefined()
165
+ expect(found!.status).toBe('running')
166
+ expect(found!.task).toBe('dual write task')
167
+ expect(found!.agentName).toBe('worker')
168
+ expect(found!.sessionFile).toBe(childFile)
169
+ expect(found!.parentRecordId).toBe('sa-parent')
170
+ // 双写过渡字段([U3 重建单规则] 无 sidecar 重建一律 idle:executionStatus 如实
171
+ // 投影两态词汇;legacy status 经桥接判据派生——无 closedReason 遗留位的 idle
172
+ // 投影 "running"(session-reader 视角的活跃成员,§3.2.8 下行映射)。
173
+ const raw = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
174
+ expect(raw.executionStatus).toBe('idle')
175
+ expect(raw.status).toBe('running')
176
+ })
177
+ })
@@ -90,6 +90,8 @@ describe('renderOutline', () => {
90
90
  // thinking 'secret'(6B) + toolResult 'output'(6B)
91
91
  expect(b.omittedBytes).toBe(12)
92
92
  expect(result.truncated).toBeUndefined()
93
+ // E7 行渲染统一:lines 随结果返回,与 turns 一一对应(预算度量 = 展示行同一份)
94
+ expect(result.lines).toEqual(['T000 · do something · bash×2,read×2 · → sure · [12B omitted]'])
93
95
  })
94
96
 
95
97
  it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
@@ -108,6 +110,25 @@ describe('renderOutline', () => {
108
110
  expect(b.toolSummary).toBe('') // 被砍(降级)
109
111
  expect(b.userBrief).toBe('u'.repeat(10)) // 骨架保留
110
112
  }
113
+ // E7:降级后的渲染行(骨架形态)与 briefs 字段同步——行内无 → 结论段、无工具聚合段
114
+ expect(result.lines).toHaveLength(3)
115
+ expect(result.lines).toEqual(['T000 · uuuuuuuuuu', 'T001 · uuuuuuuuuu', 'T002 · uuuuuuuuuu'])
116
+ expect(result.truncated).toBeUndefined()
117
+ })
118
+
119
+ it('2b. 降级(砍 assistantBrief 档):行内 → 结论段消失、toolSummary 保留,与改前语义一致', () => {
120
+ // budget=20 → perTurnCharBudget=80;level 0 行 = T000 · u*10 · bash×2 · → a*80… ≈112 > 80
121
+ // → 砍 assistantBrief 后 ≈26 ≤ 80 停(S3:该档形态与改前 handler 侧渲染降级后 briefs 等价)
122
+ const t = turn(
123
+ 0,
124
+ [uEntry('U', 'u'.repeat(10)), aEntry('A', 'a'.repeat(100), [{ name: 'bash' }, { name: 'bash' }])],
125
+ { userEntry: uEntry('U', 'u'.repeat(10)) },
126
+ )
127
+ const result = renderOutline([t], emptyTree(), { budget: 20 })
128
+
129
+ expect(result.turns[0].assistantBrief).toBe('') // 被砍
130
+ expect(result.turns[0].toolSummary).toBe('bash×2') // 保留
131
+ expect(result.lines).toEqual(['T000 · uuuuuuuuuu · bash×2'])
111
132
  expect(result.truncated).toBeUndefined()
112
133
  })
113
134
 
@@ -122,6 +143,8 @@ describe('renderOutline', () => {
122
143
 
123
144
  expect(result.truncated).toBe(3)
124
145
  expect(result.turns).toHaveLength(2)
146
+ // E7:lines 与 turns 同步截短(truncateToTotalBudget 双数组一致)
147
+ expect(result.lines).toHaveLength(2)
125
148
  })
126
149
 
127
150
  it('4. toolSummary 聚合:同名计数 ×N,不同名逗号分隔', () => {
@@ -168,6 +191,19 @@ describe('renderOutline', () => {
168
191
  expect(withoutBranches.turns[0].branch).toBeUndefined()
169
192
  })
170
193
 
194
+ it('7b. E7 旁支标记统一:渲染行 `[旁支 N entries]`(N = 该 forkPoint 下旁支子树 entry 数)', () => {
195
+ // branches: U → 3 entries 旁支子树(E7/D3:outline 与 export 两条路径共用此形态)
196
+ const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
197
+ const userEntry = uEntry('U', 'hi')
198
+ const turns = [turn(0, [userEntry], { userEntry })]
199
+
200
+ const result = renderOutline(turns, tree, { allBranches: true })
201
+ expect(result.lines).toEqual(['T000 · hi · [旁支 3 entries]'])
202
+ // branchSize 缺失(allBranches:false)不标旁支
203
+ const noBranch = renderOutline(turns, tree, { allBranches: false })
204
+ expect(noBranch.lines).toEqual(['T000 · hi'])
205
+ })
206
+
171
207
  it.skipIf(!HAS_REAL_SESSION)('8. 真实 019e6c96:outline tokenEstimate <= 1500 + assistantBrief/toolSummary 非空(v2 O1)', async () => {
172
208
  // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 变长,阈值 600→1500(design §3.3 D4)
173
209
  const parsed = await parseSessionFile(REAL_SESSION)
@@ -310,7 +346,6 @@ describe('renderDetail', () => {
310
346
  expect(summary.summary).toMatch(/^read: f\.ts \(\d+KB\)$/)
311
347
  expect(summary.totalLines).toBe(4)
312
348
  expect(summary.headLines).toContain('line1')
313
- expect(summary.fullEntry.message?.role).toBe('toolResult')
314
349
 
315
350
  // includeToolResult 全文:返回原 Entry
316
351
  const full = renderDetail([t], { includeToolResult: true })