@zhushanwen/pi-session-reader 0.11.0 → 0.13.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 (42) hide show
  1. package/package.json +5 -5
  2. package/src/__tests__/archive-ledger.test.ts +447 -0
  3. package/src/__tests__/cross-package-subagent-core.test.ts +34 -25
  4. package/src/__tests__/entry-anchor.test.ts +3 -3
  5. package/src/__tests__/execution-tree.test.ts +85 -86
  6. package/src/__tests__/family-zcode.test.ts +24 -14
  7. package/src/__tests__/family.test.ts +4 -4
  8. package/src/__tests__/find.test.ts +50 -5
  9. package/src/__tests__/index.test.ts +3 -3
  10. package/src/__tests__/rebuild-manifest-dual-write.test.ts +29 -22
  11. package/src/__tests__/render.test.ts +2 -2
  12. package/src/__tests__/result.test.ts +20 -11
  13. package/src/__tests__/roots.test.ts +64 -26
  14. package/src/__tests__/subagents.test.ts +108 -52
  15. package/src/__tests__/tool-handler.test.ts +133 -83
  16. package/src/__tests__/workflow.test.ts +5 -5
  17. package/src/__tests__/zcode-manifest.test.ts +18 -10
  18. package/src/__tests__/zcode-routing.test.ts +21 -19
  19. package/src/__tests__/zcode-whitelist.test.ts +1 -1
  20. package/src/core/family.ts +6 -6
  21. package/src/core/render.ts +2 -2
  22. package/src/core/toolcall.ts +1 -1
  23. package/src/core/workflow.ts +9 -9
  24. package/src/discovery/archive-ledger.ts +221 -0
  25. package/src/discovery/archive-match.ts +84 -0
  26. package/src/discovery/entry-anchor.ts +5 -5
  27. package/src/discovery/find.ts +12 -1
  28. package/src/discovery/roots.ts +149 -14
  29. package/src/discovery/subagents.ts +24 -17
  30. package/src/discovery/whitelist.ts +4 -4
  31. package/src/discovery/workflows.ts +8 -8
  32. package/src/discovery/zcode-manifest.ts +22 -27
  33. package/src/doctor.ts +3 -0
  34. package/src/extract.ts +1 -1
  35. package/src/index.ts +5 -2
  36. package/src/no-match.ts +29 -4
  37. package/src/result-action.ts +7 -1
  38. package/src/search-across.ts +3 -3
  39. package/src/tool-format.ts +24 -13
  40. package/src/tool-handler.ts +68 -29
  41. package/src/tui/session-command.ts +1 -1
  42. package/src/zcode-anchor-classify.ts +2 -2
@@ -228,7 +228,7 @@ describe('resolveFamily - 错误', () => {
228
228
  // ============================================================
229
229
  // U4: SubagentRef 富字段(manifest 主 / P-fallback identity 回退)
230
230
  // 验证 buildFamilyIndex 从 identity entry.data 读 task/agent/model/status/sessionFile
231
- // 填 SubagentRef,异名映射(data.agent → agentName),守卫放宽(只校验 rootSessionId+slug)。
231
+ // 填 SubagentRef,异名映射(data.agent → agentName),检查放宽(只校验 rootSessionId+slug)。
232
232
  // ============================================================
233
233
 
234
234
  describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
@@ -276,7 +276,7 @@ describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
276
276
  expect(sub?.status).toBeUndefined()
277
277
  })
278
278
 
279
- it('TC-u4-pfallback-no-identity: data 缺 rootSessionId → 守卫拒掉,不入 subagentsByRoot,不抛错', () => {
279
+ it('TC-u4-pfallback-no-identity: data 缺 rootSessionId → 检查拒掉,不入 subagentsByRoot,不抛错', () => {
280
280
  // 模拟无 identity(运行中/异常,尾行是 message 非 identity):data 只有 slug 无 rootSessionId
281
281
  const noRoot: Entry = {
282
282
  type: 'custom',
@@ -287,7 +287,7 @@ describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
287
287
  }
288
288
  const index = buildFamilyIndex([header(ROOT)], [noRoot], makeStats([ROOT]))
289
289
 
290
- // 守卫拒掉 → 不入 subagentsByRoot
290
+ // 检查拒掉 → 不入 subagentsByRoot
291
291
  expect(index.subagentsByRoot.size).toBe(0)
292
292
  // resolveFamily 不抛错,subagents 空
293
293
  const family = resolveFamily(ROOT, index)
@@ -331,7 +331,7 @@ describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
331
331
  expect(sub?.sessionFile).toBeUndefined()
332
332
  })
333
333
 
334
- it('isSubagentIdentityData 守卫放宽:富字段部分缺失/全缺都通过(只校验 rootSessionId+slug)', () => {
334
+ it('isSubagentIdentityData 检查放宽:富字段部分缺失/全缺都通过(只校验 rootSessionId+slug)', () => {
335
335
  const identities = [
336
336
  subagentIdentityRich(SUB, ROOT, 's1', { task: 'only-task' }), // 部分富字段
337
337
  subagentIdentity(SUB2, ROOT, 's2'), // 完全无富字段(旧 manifest 形态)
@@ -3,6 +3,10 @@ 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 {
7
+ resolveStoreSubagentSessionsDir,
8
+ resolveStoreSubagentRecordsDir,
9
+ } from '../discovery/roots.js'
6
10
  import { FIX_E6 } from './fixtures.js'
7
11
 
8
12
  /**
@@ -131,21 +135,31 @@ async function makeSubagentSession(
131
135
  }
132
136
 
133
137
  describe('findSessions', () => {
138
+ let root: string
134
139
  let agentDir: string
135
140
  let slugDir: string
136
- /** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,resolveSessionRoots [subagent] 根扫描路径) */
141
+ /**
142
+ * subagent fixture 目录([session-store-layout-inversion] 新布局存储树
143
+ * `<root>/<rootSessionId>/subagents/sessions/`,resolveSessionRoots [subagent] 根
144
+ * 定向扫描路径;分区键取 FIX_SID,生产同源推导函数落盘)。
145
+ */
137
146
  let saDir: string
138
147
 
139
148
  let recordsDir: string
140
149
 
150
+ /** 存储树分区 sid fixture(合法 pi sid 形态)。 */
151
+ const FIX_SID = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f1'
152
+
141
153
  beforeEach(async () => {
142
- agentDir = await mkdtemp(join(tmpdir(), 'find-test-'))
154
+ // 生产拓扑复刻(布局 B):agentDir = <dataDir>/agent
155
+ root = await mkdtemp(join(tmpdir(), 'find-test-'))
156
+ agentDir = join(root, 'agent')
143
157
  slugDir = join(agentDir, 'sessions', '--Users-demo--')
144
- saDir = join(agentDir, 'subagents', '--Users-demo--', 'sessions')
145
- recordsDir = join(agentDir, 'subagents', '--Users-demo--', 'records')
158
+ saDir = resolveStoreSubagentSessionsDir(agentDir, FIX_SID)
159
+ recordsDir = resolveStoreSubagentRecordsDir(agentDir, FIX_SID)
146
160
  })
147
161
  afterEach(async () => {
148
- await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
162
+ await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
149
163
  })
150
164
 
151
165
  it('uuid 片段匹配正确的 session(sessionId 含 query)', async () => {
@@ -359,6 +373,37 @@ describe('findSessions', () => {
359
373
  expect(matches[0].fileName).toContain('subagents')
360
374
  })
361
375
 
376
+ it('D6 单新根锚定:旧布局 <agentDir>/subagents 造 subagent 会话 → find 不发现(存量不可见)', async () => {
377
+ await makeSession(slugDir, { name: 'm.jsonl', id: 'main-shared', cwd: '/demo' })
378
+ // 旧布局存量原位保留(D6 裁决:不迁移、不再读写)
379
+ const legacyDir = join(agentDir, 'subagents', '--Users-demo--', 'sessions')
380
+ await makeSession(legacyDir, { name: 's-legacy.jsonl', id: 'sub-legacy', cwd: '/demo' })
381
+
382
+ const { matches } = await findSessions('shared', agentDir)
383
+ expect(matches.map((m) => m.sessionId)).toEqual(['main-shared'])
384
+ })
385
+
386
+ it('存储树分区目录名(= rootSessionId)不把根会话 id 的完整 id 查询吸成伪 multi', async () => {
387
+ // 新布局路径 `<root>/<rootSessionId>/subagents/sessions/<subId>.jsonl` 含根会话 id——
388
+ // 完整 id 查询若走路径子串匹配会同时命中 subagent 候选(伪 multi 消歧)。
389
+ // 精确等值短路:完整 id 直查恒精确命中(find.ts [session-store-layout-inversion])。
390
+ const rootId = '0aaaaaaa-bbbb-7ccc-dddd-0000000000q1'
391
+ const subId = '0aaaaaaa-bbbb-7ccc-dddd-0000000000q2'
392
+ await makeSession(slugDir, { name: `${rootId}.jsonl`, id: rootId, cwd: '/demo' })
393
+ await makeSession(resolveStoreSubagentSessionsDir(agentDir, rootId), {
394
+ name: `${subId}.jsonl`,
395
+ id: subId,
396
+ cwd: '/demo',
397
+ })
398
+
399
+ // 完整 id:精确等值直返,不被 subagent 路径里的分区 id 误吸
400
+ const exact = await findSessions(rootId, agentDir)
401
+ expect(exact.matches.map((m) => m.sessionId)).toEqual([rootId])
402
+ // 真片段查询:路径维度命中仍然保留(按路径片段定位文件的能力不回退)
403
+ const fragment = await findSessions('0000000000q', agentDir)
404
+ expect(fragment.matches.map((m) => m.sessionId).sort()).toEqual([rootId, subId].sort())
405
+ })
406
+
362
407
  it("recent 也尊重 source 过滤(含不传 source 时两侧按 mtime 混合倒序)", async () => {
363
408
  const mainPath = await makeSession(slugDir, { name: 'm.jsonl', id: 'r-main', cwd: '/demo' })
364
409
  const subPath = await makeSession(saDir, { name: 's.jsonl', id: 'r-sub', cwd: '/demo' })
@@ -11,9 +11,9 @@ import { Check } from 'typebox/value'
11
11
  * mock pi/ctx(as unknown as ExtensionAPI),测:
12
12
  * 1. execute 错误路径 throw(W4:pi 只对 execute throw 置 isError:true——agent-loop
13
13
  * 丢弃返回值里的 isError 字段,「错误轮被标成功」契约守护)
14
- * 2. session_start handler 的 ctx.mode!=='tui' 守卫
14
+ * 2. session_start handler 的 ctx.mode!=='tui' 检查
15
15
  * 3. registeredPis WeakSet 去重:同 pi 二次 session_start 不重复注册;不同 pi 可注册
16
- * 4. typeof ctx.ui.addAutocompleteProvider 运行时守卫(ui 缺方法 → 跳过,不崩)
16
+ * 4. typeof ctx.ui.addAutocompleteProvider 运行时检查(ui 缺方法 → 跳过,不崩)
17
17
  * 5. registerCommand + addAutocompleteProvider 组装(tui 模式下各注册一次)
18
18
  * 6. TypeBox schema 与 SessionReadParams 对齐:合法 params 过、非法 action/scope 拒
19
19
  * 7. U3 信号包采集降级:sessionManager 缺 getSessionDir 方法 / getSessionDir 抛错 →
@@ -271,7 +271,7 @@ describe('sessionReaderExtension - session_start TUI 注册', () => {
271
271
  expect(fake2.registerCommand).toHaveBeenCalledTimes(1)
272
272
  })
273
273
 
274
- it('ctx.ui 无 addAutocompleteProvider(typeof 守卫)→ 跳过注册不崩', () => {
274
+ it('ctx.ui 无 addAutocompleteProvider(typeof 检查)→ 跳过注册不崩', () => {
275
275
  const fake = makeFakePi()
276
276
  sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
277
277
  const { ctx } = makeFakeCtx({ withUi: false })
@@ -1,15 +1,15 @@
1
1
  // 跨包集成([U4c / G4-S5③] record 持久化收敛 P4c 验收锚点):
2
- // subagent-core 重建/终态写面产出的 manifest → session-reader identity 投影不降级。
2
+ // subagent-core 重建/终态写侧产出的 manifest → session-reader identity 派生视图不降级。
3
3
  //
4
4
  // 断言链(设计 subagent-record-persistence-consolidation D5 前向兼容判定 + S5③):
5
5
  // 1. manifest 被人为删除(S5 缓存可丢场景)→ subagent-core RecordStore.rebuildIndexes
6
6
  // 全量重建 → session-reader listRecordManifests 重新可见;
7
- // 2. 重建窗口前后,session-reader identity 投影的输入字段集(rootSessionId/slug/
7
+ // 2. 重建窗口前后,session-reader identity 派生视图的输入字段集(rootSessionId/slug/
8
8
  // task/agentName/model/status/sessionFile/parentRecordId)逐字段一致——双写过渡
9
- // 字段(executionStatus)在场且不被本包消费(旧 status 三态投影继续生效);
9
+ // 字段(executionStatus)在场且不被本包消费(旧 status 三态派生视图继续生效);
10
10
  // 3. 重建源 = 事件流折叠 + identity entry([③ .state 退场] 后终态位消失)——
11
11
  // 身份/富字段逐字段保真;legacy status 不再保真 closed(重建单规则恒 idle +
12
- // 无 closedReason → derivedManifestRecord 如实投影 legacy "running",终态语义
12
+ // 无 closedReason → derivedManifestRecord 如实派生 legacy "running",终态语义
13
13
  // 由 record-settled 事件帧承载)。
14
14
  //
15
15
  // 形态同 cross-package-subagent-core.test.ts:subagent-core 真实 tmpdir 产出磁盘
@@ -32,7 +32,7 @@ import { listRecordManifests, type RecordManifest } from '../discovery/subagents
32
32
 
33
33
  const ROOT_SESSION = 'root-dual-write'
34
34
 
35
- /** session-reader identity 投影(manifestIdentityData)消费的字段集——重建窗口
35
+ /** session-reader identity 派生视图(manifestIdentityData)消费的字段集——重建窗口
36
36
  * 前后必须逐字段一致(降级 = 任一字段缺失/漂移)。 */
37
37
  function identityView(m: RecordManifest) {
38
38
  return {
@@ -49,15 +49,21 @@ function identityView(m: RecordManifest) {
49
49
  }
50
50
 
51
51
  describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → session-reader 投影不降级', () => {
52
+ let dataDir: string
52
53
  let agentDir: string
53
54
  let sessionsDir: string
54
55
  let recordsDir: string
55
56
  let childFile: string
56
57
 
57
58
  beforeEach(() => {
58
- agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-rebuild-dualwrite-'))
59
- sessionsDir = getSubagentSessionDir(agentDir, agentDir)
60
- recordsDir = getSubagentRecordsDir(agentDir, agentDir)
59
+ // 生产拓扑复刻([session-store-layout-inversion 域 9]):agentDir = <dataDir>/agent
60
+ //(session-reader 的 agentDir 语义),subagent-core 写侧经新公式落存储树
61
+ // <dataDir>/ext-sessions/<rootSid>/subagents/——listRecordManifests 双根扫描的新根
62
+ //(dirname(agentDir)/ext-sessions)与写侧落点相遇
63
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-rebuild-dualwrite-'))
64
+ agentDir = path.join(dataDir, 'agent')
65
+ sessionsDir = getSubagentSessionDir(ROOT_SESSION, dataDir)
66
+ recordsDir = getSubagentRecordsDir(ROOT_SESSION, dataDir)
61
67
  fs.mkdirSync(sessionsDir, { recursive: true })
62
68
  fs.mkdirSync(recordsDir, { recursive: true })
63
69
 
@@ -95,11 +101,11 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
95
101
  })
96
102
 
97
103
  afterEach(() => {
98
- fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
104
+ fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
99
105
  })
100
106
 
101
- it('S5③: manifest 删除 → rebuildIndexes 重建 → identity 投影字段集前后一致(双写字段在场)', async () => {
102
- // ── 初始 manifest:subagent-core 终态写面(markFinalized)真实产出 ──
107
+ it('S5③: manifest 删除 → rebuildIndexes 重建 → identity 派生视图字段集前后一致(双写字段在场)', async () => {
108
+ // ── 初始 manifest:subagent-core 终态写侧(markFinalized)真实产出 ──
103
109
  const record = createRecord('sa-dw', {
104
110
  agent: 'worker',
105
111
  model: 'prov/m1',
@@ -119,7 +125,8 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
119
125
  closedReason: 'gc' as const,
120
126
  endedAt: 2000,
121
127
  })
122
- const writer = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
128
+ // [u-subagent dir 晚绑定] RecordStore 构造 = 目录取值函数(非目录字符串)
129
+ const writer = new RecordStore(() => sessionsDir, undefined, undefined, () => recordsDir)
123
130
  expect(writer.markFinalized(record, 'gc')).toBe(true)
124
131
  writer.dispose()
125
132
 
@@ -127,9 +134,9 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
127
134
  expect(before).toBeDefined()
128
135
  const viewBefore = identityView(before!)
129
136
 
130
- // 双写过渡字段在写面就位(本包不消费,但前向兼容锚要求其在场)。
137
+ // 双写过渡字段在写侧就位(本包不消费,但前向兼容锚要求其在场)。
131
138
  // [U2 两态] executionStatus 是内部权威词汇(running|idle 两态,终态概念删除)
132
- // → 终态化后恒 'idle';status 旧三态是 session-reader 直读投影(永久保留)→ 'closed'。
139
+ // → 终态化后恒 'idle';status 旧三态是 session-reader 直读派生视图(永久保留)→ 'closed'。
133
140
  const rawBefore = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
134
141
  expect(rawBefore.executionStatus).toBe('idle')
135
142
  expect(rawBefore.status).toBe('closed')
@@ -138,15 +145,15 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
138
145
  for (const f of fs.readdirSync(recordsDir)) fs.rmSync(path.join(recordsDir, f))
139
146
  expect((await listRecordManifests(agentDir)).filter((m) => m.id === 'sa-dw')).toHaveLength(0)
140
147
 
141
- const rebuilder = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
148
+ const rebuilder = new RecordStore(() => sessionsDir, undefined, undefined, () => recordsDir)
142
149
  expect(rebuilder.rebuildIndexes()).toBe(1)
143
150
  rebuilder.dispose()
144
151
 
145
- // ── session-reader 视角:重建后 identity 投影不降级 ──
152
+ // ── session-reader 视角:重建后 identity 派生视图不降级 ──
146
153
  const after = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
147
154
  expect(after).toBeDefined()
148
155
  // 字段集保真(身份/富字段逐字段一致);仅 legacy status 按 ③ 后重建语义如实
149
- // 投影 running(终态权威 = 事件流 record-settled 帧,磁盘重建无 closedReason 载体)。
156
+ // 派生 running(终态权威 = 事件流 record-settled 帧,磁盘重建无 closedReason 载体)。
150
157
  expect(identityView(after!)).toEqual({ ...viewBefore, status: 'running' })
151
158
 
152
159
  const rawAfter = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
@@ -156,10 +163,10 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
156
163
  expect(rawAfter.closedReason).toBeUndefined()
157
164
  })
158
165
 
159
- it('惰性通道:查询面(collectRecords)补建的 manifest 同样可被本包读取投影', async () => {
160
- // 不经终态写面(无 manifest 初始形态):磁盘 record 只有 identity + 无 sidecar
166
+ it('惰性通道:查询结果(collectRecords)补建的 manifest 同样可被本包读取派生视图', async () => {
167
+ // 不经终态写侧(无 manifest 初始形态):磁盘 record 只有 identity + 无 sidecar
161
168
  // → 查询触发惰性补建。
162
- const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
169
+ const store = new RecordStore(() => sessionsDir, undefined, undefined, () => recordsDir)
163
170
  expect(store.collectRecords(10).map((r) => r.id)).toContain('sa-dw')
164
171
  store.dispose()
165
172
 
@@ -172,8 +179,8 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
172
179
  expect(found!.sessionFile).toBe(childFile)
173
180
  expect(found!.parentRecordId).toBe('sa-parent')
174
181
  // 双写过渡字段([U3 重建单规则] 无 sidecar 重建一律 idle:executionStatus 如实
175
- // 投影两态词汇;legacy status 经桥接判据派生——无 closedReason 遗留位的 idle
176
- // 投影 "running"(session-reader 视角的活跃成员,§3.2.8 下行映射)。
182
+ // 派生两态词汇;legacy status 经桥接判据派生——无 closedReason 遗留位的 idle
183
+ // 派生 "running"(session-reader 视角的活跃成员,§3.2.8 下行映射)。
177
184
  const raw = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
178
185
  expect(raw.executionStatus).toBe('idle')
179
186
  expect(raw.status).toBe('running')
@@ -254,7 +254,7 @@ describe('renderOutline', () => {
254
254
  expect(result.stats.totalTurns).toBe(2)
255
255
  // T 序号 = 原 turn.index(expand/detail 的入口地址稳定,折叠不重编号)
256
256
  expect(result.turns.map((b) => b.index)).toEqual([1, 3])
257
- // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据面):2+1+1+1 = 5
257
+ // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据):2+1+1+1 = 5
258
258
  expect(result.stats.totalEntries).toBe(5)
259
259
  })
260
260
 
@@ -459,7 +459,7 @@ describe('renderDetail', () => {
459
459
  })
460
460
  })
461
461
 
462
- // 测试内联的类型守卫镜像(验证 renderDetail 剥离效果,不依赖 render 内部导出)
462
+ // 测试内联的类型检查镜像(验证 renderDetail 剥离效果,不依赖 render 内部导出)
463
463
  function extractTextForTest(content: unknown): string {
464
464
  if (typeof content === 'string') return content
465
465
  if (Array.isArray(content)) {
@@ -4,6 +4,10 @@ import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { handleSessionRead, type SessionReadParams } from '../tool-handler.js'
6
6
  import { extractFinalAssistantText } from '../result-action.js'
7
+ import {
8
+ resolveStoreSubagentSessionsDir,
9
+ resolveStoreSubagentRecordsDir,
10
+ } from '../discovery/roots.js'
7
11
 
8
12
  /**
9
13
  * U6 result action 测试(design subagent-sync-collect §3.1.3 + impl-plan U6)。
@@ -22,9 +26,11 @@ import { extractFinalAssistantText } from '../result-action.js'
22
26
  * 一致的前置,A4 取回门)。
23
27
  */
24
28
 
25
- // ---- fixture 工具(合成 subagent session,同 tool-handler.test.ts makeFixtureSubagent 结构)----
29
+ // ---- fixture 工具(合成 subagent session,同 tool-handler.test.ts makeFixtureSubagent 结构;
30
+ // [session-store-layout-inversion] 新布局存储树,分区键 = manifest.rootSessionId 常量)----
26
31
 
27
- const SLUG = '--demo-cwd--'
32
+ /** 存储树分区 sid fixture(合法 pi sid 形态;与 makeSubagent 内 manifest.rootSessionId 同值)。 */
33
+ const FIX_ROOT_SID = '0aaaaaaa-bbbb-7ccc-dddd-0000000000r1'
28
34
 
29
35
  interface SubagentFixture {
30
36
  saId: string
@@ -45,9 +51,10 @@ function assistantEntry(id: string, content: unknown): unknown {
45
51
  }
46
52
 
47
53
  async function makeSubagent(dir: string, f: SubagentFixture): Promise<string> {
48
- const sessionFile = join(dir, 'subagents', SLUG, 'sessions', `${f.realId}.jsonl`)
54
+ const sessionsDir = resolveStoreSubagentSessionsDir(dir, FIX_ROOT_SID)
55
+ const sessionFile = join(sessionsDir, `${f.realId}.jsonl`)
49
56
  if (f.sessionFileExists !== false) {
50
- await mkdir(join(dir, 'subagents', SLUG, 'sessions'), { recursive: true })
57
+ await mkdir(sessionsDir, { recursive: true })
51
58
  const header = JSON.stringify({ type: 'session', id: f.realId, cwd: '/demo' })
52
59
  const task = JSON.stringify({
53
60
  type: 'message',
@@ -58,13 +65,13 @@ async function makeSubagent(dir: string, f: SubagentFixture): Promise<string> {
58
65
  const lines = [header, task, ...(f.entries ?? []).map((e) => JSON.stringify(e))]
59
66
  await writeFile(sessionFile, lines.join('\n') + '\n')
60
67
  }
61
- const recordsDir = join(dir, 'subagents', SLUG, 'records')
68
+ const recordsDir = resolveStoreSubagentRecordsDir(dir, FIX_ROOT_SID)
62
69
  await mkdir(recordsDir, { recursive: true })
63
70
  await writeFile(
64
71
  join(recordsDir, `${f.saId}.json`),
65
72
  JSON.stringify({
66
73
  id: f.saId,
67
- rootSessionId: 'root-session-1',
74
+ rootSessionId: FIX_ROOT_SID,
68
75
  agentName: 'explorer',
69
76
  sessionFile,
70
77
  }),
@@ -120,13 +127,15 @@ describe('extractFinalAssistantText(同源语义白盒)', () => {
120
127
  // ---- result action 黑盒(fixture:sa-id manifest 反查主路径) ----
121
128
 
122
129
  describe('result action(U6)', () => {
123
- let dir: string
130
+ let dataDir: string
131
+ let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
124
132
 
125
133
  beforeEach(async () => {
126
- dir = await mkdtemp(join(tmpdir(), 'session-reader-result-'))
134
+ dataDir = await mkdtemp(join(tmpdir(), 'session-reader-result-'))
135
+ dir = join(dataDir, 'agent')
127
136
  })
128
137
  afterEach(async () => {
129
- await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
138
+ await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
130
139
  })
131
140
 
132
141
  it('1. 单 id:返回纯正文(无包装),details 带元数据(sessionId=header 真实 id)', async () => {
@@ -250,7 +259,7 @@ describe('result action(U6)', () => {
250
259
  )
251
260
  expect(cut.content[0]?.text).toBe(
252
261
  'abcde\n\n[truncated 5 of 10 chars — full text: read ' +
253
- `${join(dir, 'subagents', SLUG, 'sessions', `${realId}.jsonl`)}` +
262
+ `${join(resolveStoreSubagentSessionsDir(dir, FIX_ROOT_SID), `${realId}.jsonl`)}` +
254
263
  ', or session_read { action:"detail", session:"sa-lim" }]',
255
264
  )
256
265
  const exact = await handleSessionRead(
@@ -267,7 +276,7 @@ describe('result action(U6)', () => {
267
276
  }
268
277
  })
269
278
 
270
- it('6. 不存在 id:sa-id 无 manifest → zcode_record_not_found(U9 统一错误面);uuid 片段零匹配 → 无匹配 session', async () => {
279
+ it('6. 不存在 id:sa-id 无 manifest → zcode_record_not_found(U9 统一错误范围);uuid 片段零匹配 → 无匹配 session', async () => {
271
280
  await expect(
272
281
  handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir }),
273
282
  ).rejects.toThrow(/zcode_record_not_found/)
@@ -4,6 +4,8 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
4
  import { join, dirname } from 'node:path'
5
5
  import {
6
6
  resolveSessionRoots,
7
+ resolveSessionStoreRoot,
8
+ resolveStoreSubagentSessionsDir,
7
9
  normalizeLiveSessionDir,
8
10
  type SessionRoot,
9
11
  type SessionRootSignals,
@@ -38,6 +40,9 @@ async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n
38
40
  /** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾) */
39
41
  const SLUG = '--Users-foo--'
40
42
 
43
+ /** taiji 用例的存储树分区 sid fixture(合法 pi sid 形态)。 */
44
+ const TAIJI_SID = '0aaaaaaa-bbbb-7ccc-dddd-0000000000t1'
45
+
41
46
  describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆盖)', () => {
42
47
  let dir: string
43
48
 
@@ -124,24 +129,26 @@ describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆
124
129
  })
125
130
  })
126
131
 
127
- describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后保覆盖)', () => {
128
- let dir: string
132
+ describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后保覆盖;[session-store-layout-inversion] 单新根)', () => {
133
+ let dataDir: string
134
+ let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
129
135
 
130
136
  beforeEach(async () => {
131
- dir = await mkdtemp(join(tmpdir(), 'roots-sub-test-'))
137
+ dataDir = await mkdtemp(join(tmpdir(), 'roots-sub-test-'))
138
+ dir = join(dataDir, 'agent')
132
139
  })
133
140
  afterEach(async () => {
134
- await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
141
+ await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
135
142
  })
136
143
 
137
- it('扫描 subagents/<slug>/sessions/*.jsonl,排除 .finalized', async () => {
138
- const slug = '--Users-foo--'
139
- await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
140
- await writeFile(join(dir, 'subagents', slug, 'sessions', 'c.jsonl'), '{"type":"session"}\n')
141
- await writeFile(
142
- join(dir, 'subagents', slug, 'sessions', 'c.jsonl.finalized'),
143
- '{"type":"session"}\n',
144
- )
144
+ /** 存储树分区 sid fixture(合法 pi sid 形态)。 */
145
+ const SID_P = '0aaaaaaa-bbbb-7ccc-dddd-0000000000p1'
146
+
147
+ it('扫描存储树 <root>/<sid>/subagents/sessions/*.jsonl,排除 .finalized', async () => {
148
+ const sessionsDir = resolveStoreSubagentSessionsDir(dir, SID_P)
149
+ await mkdir(sessionsDir, { recursive: true })
150
+ await writeFile(join(sessionsDir, 'c.jsonl'), '{"type":"session"}\n')
151
+ await writeFile(join(sessionsDir, 'c.jsonl.finalized'), '{"type":"session"}\n')
145
152
 
146
153
  const result = await subagentSessionFiles(dir)
147
154
  expect(result).toHaveLength(1)
@@ -149,21 +156,47 @@ describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后
149
156
  expect(result[0].path.endsWith('.finalized')).toBe(false)
150
157
  })
151
158
 
152
- it('records/ 子目录(.json manifest)不被误收', async () => {
153
- const slug = '--Users-foo--'
154
- await mkdir(join(dir, 'subagents', slug, 'records'), { recursive: true })
155
- await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
156
- await writeFile(join(dir, 'subagents', slug, 'records', 'manifest.json'), '{}\n')
157
- await writeFile(join(dir, 'subagents', slug, 'sessions', 'sub.jsonl'), '{"type":"session"}\n')
159
+ it('records/ 子目录(.json manifest)不被误收(同分区固定位置只探 sessions/)', async () => {
160
+ const { resolveStoreSubagentRecordsDir } = await import('../discovery/roots.js')
161
+ await mkdir(resolveStoreSubagentRecordsDir(dir, SID_P), { recursive: true })
162
+ const sessionsDir = resolveStoreSubagentSessionsDir(dir, SID_P)
163
+ await mkdir(sessionsDir, { recursive: true })
164
+ await writeFile(join(resolveStoreSubagentRecordsDir(dir, SID_P), 'manifest.json'), '{}\n')
165
+ await writeFile(join(sessionsDir, 'sub.jsonl'), '{"type":"session"}\n')
158
166
 
159
167
  const result = await subagentSessionFiles(dir)
160
168
  expect(result).toHaveLength(1)
161
169
  expect(result[0].path.endsWith('sub.jsonl')).toBe(true)
162
170
  })
163
171
 
164
- it('无 subagents 目录返回 [],不抛错', async () => {
172
+ it('非会话分区目录(isPiSessionId 不过)剪枝跳过,不进 subagent 根候选', async () => {
173
+ // 非法分区名(前导 - 违反首字符规则;btw 虚拟 id 冒号形态同拒)
174
+ for (const bogus of ['-bogus', 'btw:0aaa']) {
175
+ const bogusDir = join(resolveSessionStoreRoot(dir), bogus, 'subagents', 'sessions')
176
+ await mkdir(bogusDir, { recursive: true })
177
+ await writeFile(join(bogusDir, 'x.jsonl'), '{"type":"session"}\n')
178
+ }
179
+ const okDir = resolveStoreSubagentSessionsDir(dir, SID_P)
180
+ await mkdir(okDir, { recursive: true })
181
+ await writeFile(join(okDir, 'ok.jsonl'), '{"type":"session"}\n')
182
+
183
+ const result = await subagentSessionFiles(dir)
184
+ expect(result).toHaveLength(1)
185
+ expect(result[0]!.path.endsWith('ok.jsonl')).toBe(true)
186
+ })
187
+
188
+ it('无存储树目录返回 [],不抛错', async () => {
165
189
  await expect(subagentSessionFiles(dir)).resolves.toEqual([])
166
190
  })
191
+
192
+ it('D6 单新根锚定:旧布局 <agentDir>/subagents 造会话文件不被扫描(存量不可见)', async () => {
193
+ // 旧布局存量原位保留(D6 裁决:不迁移、不再读写)
194
+ const legacyDir = join(dir, 'subagents', '--legacy-cwd--', 'sessions')
195
+ await mkdir(legacyDir, { recursive: true })
196
+ await writeFile(join(legacyDir, 'legacy.jsonl'), '{"type":"session"}\n')
197
+
198
+ expect(await subagentSessionFiles(dir)).toEqual([])
199
+ })
167
200
  })
168
201
 
169
202
  // ============================================================
@@ -222,7 +255,8 @@ const HOST_CASES: HostCase[] = [
222
255
  const agentDir = join(dataDir, 'agent')
223
256
  await writeJsonl(join(agentDir, 'sessions', SLUG, 'x.jsonl'))
224
257
  await writeJsonl(join(dataDir, 'sessions', 'old.jsonl')) // 旧旧布局迁移残留(§6.13 U1 行)
225
- await writeJsonl(join(agentDir, 'subagents', SLUG, 'sessions', 's.jsonl'))
258
+ // [session-store-layout-inversion] subagent 会话在新布局存储树(生产同源推导落盘)
259
+ await writeJsonl(join(resolveStoreSubagentSessionsDir(agentDir, TAIJI_SID), 's.jsonl'))
226
260
  return { agentDir, signals: { agentDir, liveSessionDir: join(agentDir, 'sessions') } }
227
261
  },
228
262
  verify({ agentDir, tmp }, roots) {
@@ -273,11 +307,12 @@ describe('resolveSessionRoots', () => {
273
307
  const { agentDir, signals } = await build(tmp)
274
308
  const roots = await resolveSessionRoots(signals)
275
309
 
276
- // ④ subagent 根恒在(常量推导,不来自任何信号,§6.1 信号 5)
310
+ // ④ subagent 根恒在(常量推导,不来自任何信号,§6.1 信号 5);
311
+ // [session-store-layout-inversion] 根 = session 存储树(dirname(agentDir)/ext-sessions)
277
312
  const sub = roots.find((r) => r.kind === 'subagent')
278
313
  expect(sub).toBeDefined()
279
314
  expect(sub!.source).toBe('subagent')
280
- expect(sub!.path).toBe(join(agentDir, 'subagents'))
315
+ expect(sub!.path).toBe(resolveSessionStoreRoot(agentDir))
281
316
 
282
317
  // ⑤ 已扫描根 fileCount/scanMs 恒填充且与 files 一致;被去重根不产生扫描统计
283
318
  for (const r of roots) {
@@ -389,8 +424,11 @@ describe('resolveSessionRoots options(u8:subagents 模式)', () => {
389
424
  const agentDir = join(tmp, 'agent')
390
425
  await mkdir(join(agentDir, 'sessions', SLUG), { recursive: true })
391
426
  await writeFile(join(agentDir, 'sessions', SLUG, 'a.jsonl'), '{"type":"session"}\n')
392
- await mkdir(join(agentDir, 'subagents', SLUG, 'sessions'), { recursive: true })
393
- await writeFile(join(agentDir, 'subagents', SLUG, 'sessions', 's.jsonl'), '{"type":"session"}\n')
427
+ await mkdir(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-0000000000o1'), { recursive: true })
428
+ await writeFile(
429
+ join(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-0000000000o1'), 's.jsonl'),
430
+ '{"type":"session"}\n',
431
+ )
394
432
  return { tmp, agentDir }
395
433
  }
396
434
 
@@ -428,8 +466,8 @@ describe('resolveSessionRoots options(u8:subagents 模式)', () => {
428
466
  })
429
467
 
430
468
  it('重复调用每次实扫(无缓存):两次结果独立完整,files 恒非空实扫值', async () => {
431
- // 原三个 cache 用例(set 回写 / 命中优先 / stat × cache)随缓存注入面删除
432
- // (ext-simplify-04 U3);本用例钉死删除后契约——不存在任何缓存层
469
+ // 原三个 cache 用例(set 回写 / 命中优先 / stat × cache)随缓存注入点删除
470
+ // (ext-simplify-04 U3);本用例固定删除后契约——不存在任何缓存层
433
471
  const { tmp, agentDir } = await buildMinimalFixture()
434
472
  try {
435
473
  const first = await resolveSessionRoots({ agentDir })