@zhushanwen/pi-session-reader 0.4.0 → 0.5.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,4 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
2
5
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
3
6
  import { Check } from 'typebox/value'
4
7
 
@@ -13,19 +16,26 @@ import { Check } from 'typebox/value'
13
16
  * 4. typeof ctx.ui.addAutocompleteProvider 运行时守卫(ui 缺方法 → 跳过,不崩)
14
17
  * 5. registerCommand + addAutocompleteProvider 组装(tui 模式下各注册一次)
15
18
  * 6. TypeBox schema 与 SessionReadParams 对齐:合法 params 过、非法 action/scope 拒
19
+ * 7. U3 信号包采集降级:sessionManager 缺 getSessionDir 方法 / getSessionDir 抛错 →
20
+ * liveSessionDir 降级 undefined,find 正常返回不抛(ctx===undefined 形态由 1 覆盖)
16
21
  *
17
22
  * 不触碰真实文件系统:getAgentDir mock 固定假路径(execute 用例只触发 F5 抛错路径,
18
23
  * 不读盘)。
19
24
  */
20
25
 
21
26
  // vi.mock 在 import 前 hoist;SessionManager stub 仅防 session-command/hash-provider
22
- // 模块加载期缺导出(本文件用例不调用它)
27
+ // 模块加载期缺导出(本文件用例不调用它)。u11:state/listAll 经 vi.hoisted 可控——
28
+ // getAgentDir 可指向真实 tmp fixture,listAll 为 spy,供 execute 接线断言。
29
+ const piMocks = vi.hoisted(() => {
30
+ const state = { agentDir: '/tmp/pi-session-reader-test-agent' }
31
+ const listAll = vi.fn(async (_dir: string) => [] as Array<Record<string, unknown>>)
32
+ return { state, listAll }
33
+ })
34
+
23
35
  vi.mock('@earendil-works/pi-coding-agent', () => ({
24
- getAgentDir: () => '/tmp/pi-session-reader-test-agent',
36
+ getAgentDir: () => piMocks.state.agentDir,
25
37
  SessionManager: class {
26
- static async listAll(): Promise<never[]> {
27
- return []
28
- }
38
+ static listAll = piMocks.listAll
29
39
  },
30
40
  }))
31
41
 
@@ -99,6 +109,119 @@ describe('sessionReaderExtension - execute 契约', () => {
99
109
  })
100
110
  })
101
111
 
112
+ describe('sessionReaderExtension - execute 信号包采集(U3:ctx 全形态降级)', () => {
113
+ type ExecFn = (
114
+ toolCallId: string,
115
+ params: unknown,
116
+ signal: AbortSignal | undefined,
117
+ onUpdate: unknown,
118
+ ctx: unknown,
119
+ ) => Promise<{ content: Array<{ type: string; text: string }> }>
120
+
121
+ let execute: ExecFn
122
+
123
+ beforeEach(() => {
124
+ const fake = makeFakePi()
125
+ sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
126
+ execute = (fake.registerTool.mock.calls[0][0] as { execute: ExecFn }).execute
127
+ })
128
+
129
+ // find 零匹配不抛(返回提示文本)——用例只断言「正常返回」降级语义,不碰真实数据目录
130
+ //(mock agentDir = /tmp/pi-session-reader-test-agent 及其派生根均不存在,扫描瞬时零文件)
131
+ const FIND_NO_MATCH = { action: 'find', query: 'zzznoindexmatch' } as const
132
+
133
+ it('sessionManager 存在但缺 getSessionDir 方法 → 降级 liveSessionDir=undefined,find 正常返回不抛', async () => {
134
+ const ctx = { mode: 'tui', sessionManager: {} }
135
+ const r = await execute('tc-u3-1', FIND_NO_MATCH, undefined, undefined, ctx)
136
+ expect(r.content[0]?.type).toBe('text')
137
+ })
138
+
139
+ it('getSessionDir 调用抛错 → 同样降级不抛(可选链只防缺失,调用抛错由 try/catch 兜住)', async () => {
140
+ const ctx = {
141
+ mode: 'tui',
142
+ sessionManager: {
143
+ getSessionDir: () => {
144
+ throw new Error('boom')
145
+ },
146
+ },
147
+ }
148
+ const r = await execute('tc-u3-2', FIND_NO_MATCH, undefined, undefined, ctx)
149
+ expect(r.content[0]?.type).toBe('text')
150
+ })
151
+
152
+ it('ctx 提供正常 getSessionDir → 信号被采集(调用一次)且 find 正常返回', async () => {
153
+ const getSessionDir = vi.fn(() => '/tmp/pi-session-reader-test-live-dir')
154
+ const ctx = { mode: 'tui', sessionManager: { getSessionDir } }
155
+ const r = await execute('tc-u3-3', FIND_NO_MATCH, undefined, undefined, ctx)
156
+ expect(getSessionDir).toHaveBeenCalledTimes(1)
157
+ expect(r.content[0]?.type).toBe('text')
158
+ })
159
+
160
+ it('u8:doctor 经 execute 走通——env/bundleUrl 补采 + ctx===undefined 降级仍出环境判定与根表', async () => {
161
+ // mock agentDir(/tmp/pi-session-reader-test-agent)及其派生根均不存在 → 根表全空仍渲染;
162
+ // 残留 glob 基点 = /tmp,只读 readdir(无写操作),不触碰任何真实数据目录。
163
+ const r = await execute('tc-u8-1', { action: 'doctor' }, undefined, undefined, undefined)
164
+ const text = r.content[0]?.text as string
165
+ expect(text).toContain('环境判定:')
166
+ // bundleUrl 信号已采集:测试内 import.meta.url 为仓库源码路径 → dev(非打包资源目录)
167
+ expect(text).toContain('发行形态:dev')
168
+ expect(text).toContain('会话根(按优先级)')
169
+ })
170
+
171
+ it('u11:execute 构造 metadataProvider 接 SessionManager.listAll——keyword 标题命中端到端', async () => {
172
+ // fixture:平铺 main 根(xyz-agent 形态),session 首消息不含 query,标题经 mock listAll 注入
173
+ const tmp = await mkdtemp(join(tmpdir(), 'index-u11-'))
174
+ try {
175
+ const agentDir = join(tmp, 'agent')
176
+ const liveDir = join(agentDir, 'sessions')
177
+ await mkdir(liveDir, { recursive: true })
178
+ const id = 'm-wire-0001'
179
+ await writeFile(
180
+ join(liveDir, 'a.jsonl'),
181
+ [
182
+ JSON.stringify({ type: 'session', id, cwd: '/demo' }),
183
+ JSON.stringify({
184
+ type: 'message',
185
+ id: `${id}-m1`,
186
+ message: { role: 'user', content: [{ type: 'text', text: '完全无关的首消息' }] },
187
+ }),
188
+ ].join('\n') + '\n',
189
+ )
190
+ piMocks.state.agentDir = agentDir
191
+ piMocks.listAll.mockResolvedValue([
192
+ {
193
+ path: join(liveDir, 'a.jsonl'),
194
+ id,
195
+ cwd: '/demo',
196
+ name: '接线标题命中',
197
+ modified: new Date(),
198
+ firstMessage: '完全无关的首消息',
199
+ created: new Date(),
200
+ messageCount: 1,
201
+ allMessagesText: '完全无关的首消息',
202
+ },
203
+ ])
204
+ const ctx = { mode: 'rpc', sessionManager: { getSessionDir: () => liveDir } }
205
+ const r = await execute('tc-u11-1', { action: 'find', query: '接线标题' }, undefined, undefined, ctx)
206
+ // provider 已接线:listAll 被调,且实参恒为非空串(永传非空串 guard)
207
+ expect(piMocks.listAll).toHaveBeenCalledTimes(1)
208
+ expect(piMocks.listAll.mock.calls[0][0]).toBe(liveDir)
209
+ expect(piMocks.listAll.mock.calls[0][0].length).toBeGreaterThan(0)
210
+ // 标题命中路径端到端:文本与 details 都带 name
211
+ const text = r.content[0]?.text as string
212
+ expect(text).toContain('接线标题命中')
213
+ const d = r.details as { matches: Array<{ sessionId: string; name?: string }> }
214
+ expect(d.matches[0]?.sessionId).toBe(id)
215
+ expect(d.matches[0]?.name).toBe('接线标题命中')
216
+ } finally {
217
+ // 还原 mock 态,不污染同文件其他用例
218
+ piMocks.state.agentDir = '/tmp/pi-session-reader-test-agent'
219
+ piMocks.listAll.mockReset()
220
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
221
+ }
222
+ })
223
+ })
224
+
102
225
  describe('sessionReaderExtension - session_start TUI 注册', () => {
103
226
  it('mode !== tui → 不注册 command/provider', () => {
104
227
  const fake = makeFakePi()
@@ -237,4 +360,33 @@ describe('sessionReaderExtension - TypeBox schema 与 SessionReadParams 对齐',
237
360
  expect(Check(schema, { action: 'family', session: 'x', recursive: 'yes' })).toBe(false)
238
361
  expect(Check(schema, { action: 'family', session: 'x', recursive: 1 })).toBe(false)
239
362
  })
363
+
364
+ it('limit 退化输入:minimum:1 在 schema 校验层拒绝 0/负数(不落入 find F1「无匹配」措辞面)', () => {
365
+ const fake = makeFakePi()
366
+ sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
367
+ const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
368
+ const schema = toolDef.parameters
369
+
370
+ // 0/负数被 schema 拒绝(minimum:1),不会进入 find/F1 输出「无匹配 session」
371
+ expect(Check(schema, { action: 'find', query: 'x', limit: 0 })).toBe(false)
372
+ expect(Check(schema, { action: 'find', query: 'x', limit: -3 })).toBe(false)
373
+ // 正数合法;result 消费同一 limit 字段(正数合法)
374
+ expect(Check(schema, { action: 'find', query: 'x', limit: 1 })).toBe(true)
375
+ expect(Check(schema, { action: 'result', session: 'sa-x', limit: 100 })).toBe(true)
376
+ // 不传 limit 向后兼容(缺省语义不变)
377
+ expect(Check(schema, { action: 'find', query: 'x' })).toBe(true)
378
+ })
379
+
380
+ it('TC-u8-schema-doctor:action enum 含 doctor + includeSubagents optional boolean', () => {
381
+ const fake = makeFakePi()
382
+ sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
383
+ const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
384
+ const schema = toolDef.parameters
385
+
386
+ expect(Check(schema, { action: 'doctor' })).toBe(true)
387
+ expect(Check(schema, { action: 'doctor', includeSubagents: true })).toBe(true)
388
+ expect(Check(schema, { action: 'doctor', includeSubagents: false })).toBe(true)
389
+ expect(Check(schema, { action: 'doctor', includeSubagents: 'yes' })).toBe(false)
390
+ expect(Check(schema, { action: 'doctor', includeSubagents: 1 })).toBe(false)
391
+ })
240
392
  })
@@ -16,6 +16,16 @@ import { join } from 'node:path'
16
16
  /** 真实 pi agent 目录(本机),用于集成测试。 */
17
17
  export const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
18
18
 
19
+ /**
20
+ * 全量扫描类真实数据用例的统一超时标定(90s)。
21
+ *
22
+ * 这类用例遍历本机 ~/.pi/agent 全量会话语料(实测 4600+ 文件),空载单用例已需 6-10s;
23
+ * 38 包并行全量测试时 CPU/IO 争抢可降速 3-4 倍,30s 标定必假红。90s ≈ 空载 9 倍余量,
24
+ * 仍能兜住真正的挂死。仅用于遍历全量语料的用例(findSessions / buildFamilyFromFs /
25
+ * listMainSessions 等);读已知单文件的用例不需要放宽。
26
+ */
27
+ export const REAL_DATA_TIMEOUT_MS = 90_000
28
+
19
29
  /** 5.4MB / 32 turn / 1204 entry 的真实 session(feat-plugin-arch-3 目录)。 */
20
30
  export const E6 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
21
31
  /** 真实 fork 家族根(fork 子代 019fe632,隔代 subagent 019fe635 挂在 019fe632 下)。 */
@@ -330,12 +330,12 @@ describe('result action(U6)', () => {
330
330
  ).rejects.toThrow(/空条目/)
331
331
  })
332
332
 
333
- it('10. 未知 action 报错文案含 result(10 action 全列举)', async () => {
333
+ it('10. 未知 action 报错文案含 result(11 action 全列举,含 doctor)', async () => {
334
334
  await expect(
335
335
  handleSessionRead(
336
336
  { action: 'bogus' } as unknown as SessionReadParams,
337
337
  dir,
338
338
  ),
339
- ).rejects.toThrow(/find\/family\/outline\/expand\/detail\/search\/export\/extract\/workflow\/result/)
339
+ ).rejects.toThrow(/find\/family\/outline\/expand\/detail\/search\/export\/extract\/workflow\/result\/doctor/)
340
340
  })
341
341
  })
@@ -1,9 +1,26 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
- import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
- import { join } from 'node:path'
5
- import { listMainSessions, listSubagentSessions } from '../discovery/roots.js'
6
- import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR } from './real-data.js'
3
+ import { mkdtemp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
4
+ import { join, dirname } from 'node:path'
5
+ import {
6
+ listMainSessions,
7
+ listSubagentSessions,
8
+ resolveSessionRoots,
9
+ normalizeLiveSessionDir,
10
+ type SessionRoot,
11
+ type SessionRootSignals,
12
+ } from '../discovery/roots.js'
13
+ import { buildFamilyFromFs } from '../discovery/subagents.js'
14
+ import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_AGENT_DIR, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
15
+
16
+ /** 写 .jsonl fixture(父目录不存在则自建),内容默认合法 session header */
17
+ async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n') {
18
+ await mkdir(dirname(path), { recursive: true })
19
+ await writeFile(path, content)
20
+ }
21
+
22
+ /** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾,实证见 real-data.ts) */
23
+ const SLUG = '--Users-foo--'
7
24
 
8
25
  describe('listMainSessions', () => {
9
26
  let dir: string
@@ -80,7 +97,7 @@ describe('listMainSessions', () => {
80
97
  expect(result.every((m) => !m.path.split('/').pop()!.startsWith('wf-'))).toBe(true)
81
98
  // mtime/size 真实
82
99
  expect(result.every((m) => m.mtime > 0 && m.size > 0)).toBe(true)
83
- }, 30000)
100
+ }, REAL_DATA_TIMEOUT_MS)
84
101
  })
85
102
 
86
103
  describe('listSubagentSessions', () => {
@@ -128,5 +145,341 @@ describe('listSubagentSessions', () => {
128
145
  const result = await listSubagentSessions(REAL_AGENT_DIR)
129
146
  expect(result.length).toBeGreaterThan(0)
130
147
  expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
131
- }, 30000)
148
+ }, REAL_DATA_TIMEOUT_MS)
149
+ })
150
+
151
+ // ============================================================
152
+ // U1 resolveSessionRoots(design 2026-09-10 §6.1 / §7B 要点 2/3/4/6/7;B 收缩无 [env])
153
+ // ============================================================
154
+
155
+ /** 三宿主信号包用例的行内校验上下文 */
156
+ interface HostCaseCtx {
157
+ tmp: string
158
+ agentDir: string
159
+ }
160
+
161
+ interface HostCase {
162
+ name: string
163
+ build: (tmp: string) => Promise<{ agentDir: string; signals: SessionRootSignals }>
164
+ verify: (ctx: HostCaseCtx, roots: SessionRoot[]) => void
165
+ }
166
+
167
+ const HOST_CASES: HostCase[] = [
168
+ {
169
+ name: '纯 pi:live=encodeCwd 子目录,剥层后与 default 同路径去重,保留 live 标签',
170
+ async build(tmp) {
171
+ const agentDir = join(tmp, 'agent')
172
+ await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
173
+ return {
174
+ agentDir,
175
+ signals: { agentDir, liveSessionDir: join(agentDir, 'sessions', SLUG) },
176
+ }
177
+ },
178
+ verify({ agentDir }, roots) {
179
+ // kind 序 = 优先级序(live > default > legacy > subagent;B 收缩无 env)
180
+ expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
181
+ const live = roots.find((r) => r.kind === 'live')!
182
+ // 剥层(§7B 要点 3):父目录名为 sessions 的真实形态 → dirname
183
+ expect(live.path).toBe(join(agentDir, 'sessions'))
184
+ expect(live.exists).toBe(true)
185
+ expect(live.fileCount).toBe(1)
186
+ const def = roots.find((r) => r.kind === 'default')!
187
+ expect(def.path).toBe(join(agentDir, 'sessions'))
188
+ // realpath 去重(§7B 要点 4):同路径只扫一次,保留最高优先级 kind
189
+ expect(def.dedupedInto).toBe('live')
190
+ expect(def.files).toHaveLength(0)
191
+ expect(def.fileCount).toBeUndefined()
192
+ // 该路径在全列表中只被一个根实扫(文件不重复计入)
193
+ const scannedAtSamePath = roots.filter(
194
+ (r) => r.path === join(agentDir, 'sessions') && r.dedupedInto === undefined,
195
+ )
196
+ expect(scannedAtSamePath).toHaveLength(1)
197
+ expect(roots.reduce((n, r) => n + r.files.length, 0)).toBe(1)
198
+ },
199
+ },
200
+ {
201
+ name: 'xyz-agent:live=根本身(RPC 覆盖态),default 去重,legacy 残留与 subagent 独立列出',
202
+ async build(tmp) {
203
+ const dataDir = join(tmp, 'data')
204
+ const agentDir = join(dataDir, 'agent')
205
+ await writeJsonl(join(agentDir, 'sessions', SLUG, 'x.jsonl'))
206
+ await writeJsonl(join(dataDir, 'sessions', 'old.jsonl')) // 旧旧布局迁移残留(§6.13 U1 行)
207
+ await writeJsonl(join(agentDir, 'subagents', SLUG, 'sessions', 's.jsonl'))
208
+ return { agentDir, signals: { agentDir, liveSessionDir: join(agentDir, 'sessions') } }
209
+ },
210
+ verify({ agentDir, tmp }, roots) {
211
+ expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
212
+ // live=根本身(basename 非 encodeCwd 形态)→ 取自身,与 default 同字面路径 → 去重
213
+ const scanned = roots.filter((r) => r.dedupedInto === undefined)
214
+ expect(scanned.map((r) => r.kind)).toEqual(['live', 'legacy', 'subagent'])
215
+ const live = scanned[0]!
216
+ expect(live.path).toBe(join(agentDir, 'sessions'))
217
+ expect(live.fileCount).toBe(1) // 递归扫到 encodeCwd 子目录内的文件
218
+ const def = roots.find((r) => r.kind === 'default')!
219
+ expect(def.dedupedInto).toBe('live')
220
+ // legacy(<dataDir>/sessions 残留)非空即纳入候选,独立成根
221
+ const legacy = roots.find((r) => r.kind === 'legacy')!
222
+ expect(legacy.path).toBe(join(tmp, 'data', 'sessions'))
223
+ expect(legacy.exists).toBe(true)
224
+ expect(legacy.fileCount).toBe(1)
225
+ const sub = roots.find((r) => r.kind === 'subagent')!
226
+ expect(sub.exists).toBe(true)
227
+ expect(sub.fileCount).toBe(1)
228
+ expect(sub.files[0]!.path.endsWith('s.jsonl')).toBe(true)
229
+ },
230
+ },
231
+ {
232
+ name: 'liveSessionDir 缺失降级:default/legacy/subagent 三根仍可用,无 live 根',
233
+ async build(tmp) {
234
+ const agentDir = join(tmp, 'agent')
235
+ await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
236
+ return { agentDir, signals: { agentDir } }
237
+ },
238
+ verify({ agentDir, tmp }, roots) {
239
+ expect(roots.map((r) => r.kind)).toEqual(['default', 'legacy', 'subagent'])
240
+ const def = roots.find((r) => r.kind === 'default')!
241
+ expect(def.path).toBe(join(agentDir, 'sessions'))
242
+ expect(def.exists).toBe(true)
243
+ expect(def.fileCount).toBe(1)
244
+ expect(roots.find((r) => r.kind === 'legacy')!.path).toBe(join(tmp, 'sessions'))
245
+ // live 缺席 → 无去重发生
246
+ expect(roots.every((r) => r.dedupedInto === undefined)).toBe(true)
247
+ },
248
+ },
249
+ ]
250
+
251
+ describe('resolveSessionRoots', () => {
252
+ it.each(HOST_CASES)('$name', async ({ build, verify }) => {
253
+ const tmp = await mkdtemp(join(tmpdir(), 'roots-resolve-'))
254
+ try {
255
+ const { agentDir, signals } = await build(tmp)
256
+ const roots = await resolveSessionRoots(signals)
257
+
258
+ // ④ subagent 根恒在(常量推导,不来自任何信号,§6.1 信号 5)
259
+ const sub = roots.find((r) => r.kind === 'subagent')
260
+ expect(sub).toBeDefined()
261
+ expect(sub!.source).toBe('subagent')
262
+ expect(sub!.path).toBe(join(agentDir, 'subagents'))
263
+
264
+ // ⑤ 已扫描根 fileCount/scanMs 恒填充且与 files 一致;被去重根不产生扫描统计
265
+ for (const r of roots) {
266
+ if (r.dedupedInto !== undefined) {
267
+ expect(r.fileCount).toBeUndefined()
268
+ expect(r.scanMs).toBeUndefined()
269
+ } else {
270
+ expect(typeof r.fileCount).toBe('number')
271
+ expect(r.fileCount).toBe(r.files.length)
272
+ expect(typeof r.scanMs).toBe('number')
273
+ expect(r.scanMs!).toBeGreaterThanOrEqual(0)
274
+ }
275
+ }
276
+
277
+ verify({ tmp, agentDir }, roots)
278
+ } finally {
279
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
280
+ }
281
+ })
282
+
283
+ it('[live] 规范化第二形态:普通目录(basename 非 encodeCwd 形态)取自身,不剥层', async () => {
284
+ const tmp = await mkdtemp(join(tmpdir(), 'roots-live-plain-'))
285
+ try {
286
+ const agentDir = join(tmp, 'agent')
287
+ const liveDir = join(tmp, 'live-sessions')
288
+ await writeJsonl(join(liveDir, 'b.jsonl'))
289
+
290
+ const roots = await resolveSessionRoots({ agentDir, liveSessionDir: liveDir })
291
+ const live = roots.find((r) => r.kind === 'live')!
292
+ expect(live.path).toBe(liveDir)
293
+ expect(live.exists).toBe(true)
294
+ expect(live.fileCount).toBe(1)
295
+ // default/legacy 独立列出,互不干扰
296
+ expect(roots.find((r) => r.kind === 'default')!.path).toBe(join(agentDir, 'sessions'))
297
+ expect(roots.every((r) => r.dedupedInto === undefined)).toBe(true)
298
+ } finally {
299
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
300
+ }
301
+ })
302
+
303
+ it('空 agentDir 防御:不派生任何根(避免扫到进程 cwd 相对路径)', async () => {
304
+ await expect(resolveSessionRoots({ agentDir: '' })).resolves.toEqual([])
305
+ })
306
+
307
+ it.skipIf(!HAS_REAL_AGENT_DIR)(
308
+ '§11.2 真实路径集合回归:encodeCwd 判据在真实 sessions 子目录上自洽,剥层分支被真实数据覆盖',
309
+ async () => {
310
+ const sessionsDir = join(REAL_AGENT_DIR, 'sessions')
311
+ const entries = await readdir(sessionsDir, { withFileTypes: true })
312
+ const dirs = entries.filter((e) => e.isDirectory())
313
+ expect(dirs.length).toBeGreaterThan(0)
314
+ let encodeCwdCount = 0
315
+ for (const d of dirs) {
316
+ const asLive = join(sessionsDir, d.name)
317
+ const matchesShape = d.name.startsWith('--') && d.name.endsWith('--')
318
+ // 判据自洽:匹配形态 → 剥层到父目录;不匹配(如 permission-forwarding/)→ 取自身
319
+ expect(normalizeLiveSessionDir(asLive)).toBe(matchesShape ? sessionsDir : asLive)
320
+ if (matchesShape) encodeCwdCount++
321
+ }
322
+ // 真实集合以 encodeCwd 目录为主体——剥层分支确实被真实数据覆盖
323
+ expect(encodeCwdCount).toBeGreaterThan(0)
324
+ },
325
+ 30000,
326
+ )
327
+ })
328
+
329
+ describe('buildFamilyFromFs not-found 文案(U1 并入:列实际扫描候选根)', () => {
330
+ let tmp: string
331
+
332
+ beforeEach(async () => {
333
+ tmp = await mkdtemp(join(tmpdir(), 'roots-notfound-'))
334
+ })
335
+ afterEach(async () => {
336
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
337
+ })
338
+
339
+ it('错误信息含各候选根路径、kind 标签与文件数(default/legacy),保留 findSessions 指引', async () => {
340
+ const agentDir = join(tmp, 'data', 'agent')
341
+ await writeJsonl(
342
+ join(agentDir, 'sessions', SLUG, 'known.jsonl'),
343
+ '{"type":"session","id":"known-id"}\n',
344
+ )
345
+
346
+ const msg = await buildFamilyFromFs('missing-id', agentDir).then(
347
+ () => {
348
+ throw new Error('buildFamilyFromFs should reject for unknown sessionId')
349
+ },
350
+ (e: unknown) => (e instanceof Error ? e.message : String(e)),
351
+ )
352
+ expect(msg).toContain('not found')
353
+ expect(msg).toContain(join(agentDir, 'sessions'))
354
+ expect(msg).toContain('[default]')
355
+ expect(msg).toContain('1 file(s)')
356
+ expect(msg).toContain(join(tmp, 'data', 'sessions'))
357
+ expect(msg).toContain('[legacy]')
358
+ expect(msg).toContain('not exists')
359
+ // 只列 main 候选根(collectMainSessions 实扫集合),不含 subagent 根
360
+ expect(msg).not.toContain('[subagent]')
361
+ expect(msg).toContain('findSessions')
362
+ })
363
+ })
364
+
365
+ // ============================================================
366
+ // u8 追加:resolveSessionRoots options(doctor 统一数据源,design §6.3「同一数据源
367
+ // 两处渲染,不重复实现探测」——subagent 扫描模式与进程内缓存经 options 由调用方
368
+ // 驱动,本模块不内置 TTL/mtime 失效逻辑)
369
+ // ============================================================
370
+
371
+ describe('resolveSessionRoots options(u8:subagents 模式 + 注入式缓存)', () => {
372
+ /** 最小 fixture:default 根 1 文件 + subagent 根 1 文件(legacy 不存在) */
373
+ async function buildMinimalFixture(): Promise<{ tmp: string; agentDir: string }> {
374
+ const tmp = await mkdtemp(join(tmpdir(), 'roots-opts-'))
375
+ const agentDir = join(tmp, 'agent')
376
+ await mkdir(join(agentDir, 'sessions', SLUG), { recursive: true })
377
+ await writeFile(join(agentDir, 'sessions', SLUG, 'a.jsonl'), '{"type":"session"}\n')
378
+ await mkdir(join(agentDir, 'subagents', SLUG, 'sessions'), { recursive: true })
379
+ await writeFile(join(agentDir, 'subagents', SLUG, 'sessions', 's.jsonl'), '{"type":"session"}\n')
380
+ return { tmp, agentDir }
381
+ }
382
+
383
+ it("subagents:'stat':subagent 根只 stat(无计数/耗时/files 空),main 根照常实扫", async () => {
384
+ const { tmp, agentDir } = await buildMinimalFixture()
385
+ try {
386
+ const roots = await resolveSessionRoots({ agentDir }, { subagents: 'stat' })
387
+ const sub = roots.find((r) => r.kind === 'subagent')!
388
+ expect(sub.exists).toBe(true)
389
+ expect(sub.fileCount).toBeUndefined()
390
+ expect(sub.scanMs).toBeUndefined()
391
+ expect(sub.files).toEqual([])
392
+ expect(sub.dedupedInto).toBeUndefined()
393
+ // main 根不受影响:照常实扫
394
+ const def = roots.find((r) => r.kind === 'default')!
395
+ expect(def.fileCount).toBe(1)
396
+ expect(def.files).toHaveLength(1)
397
+ } finally {
398
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
399
+ }
400
+ })
401
+
402
+ it("options 缺省与 subagents:'scan':subagent 根照旧全扫(现有调用方零破坏)", async () => {
403
+ const { tmp, agentDir } = await buildMinimalFixture()
404
+ try {
405
+ for (const options of [undefined, { subagents: 'scan' as const }]) {
406
+ const roots = await resolveSessionRoots({ agentDir }, options)
407
+ const sub = roots.find((r) => r.kind === 'subagent')!
408
+ expect(sub.fileCount).toBe(1)
409
+ expect(sub.files).toHaveLength(1)
410
+ }
411
+ } finally {
412
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
413
+ }
414
+ })
415
+
416
+ it('cache:未命中实扫后 set 回写;命中以缓存值构造根(cached 标记、files 空、不再回写)', async () => {
417
+ const { tmp, agentDir } = await buildMinimalFixture()
418
+ try {
419
+ const store = new Map<string, { exists: boolean; fileCount: number; scanMs: number }>()
420
+ const setSpy = vi.fn((key: string, value: { exists: boolean; fileCount: number; scanMs: number }) => {
421
+ store.set(key, value)
422
+ })
423
+ const getSpy = vi.fn(async (key: string) => store.get(key))
424
+ const cache = { get: getSpy, set: setSpy }
425
+
426
+ // 首扫:get 未命中 → 实扫 → 逐非去重根 set(default + legacy + subagent = 3)
427
+ const first = await resolveSessionRoots({ agentDir }, { cache })
428
+ const firstDef = first.find((r) => r.kind === 'default')!
429
+ expect(firstDef.cached).toBeUndefined()
430
+ expect(firstDef.fileCount).toBe(1)
431
+ expect(getSpy).toHaveBeenCalled()
432
+ expect(setSpy).toHaveBeenCalledTimes(3)
433
+
434
+ // 二扫:命中 → 缓存值即数据源,无新回写
435
+ const second = await resolveSessionRoots({ agentDir }, { cache })
436
+ const secondDef = second.find((r) => r.kind === 'default')!
437
+ expect(secondDef.cached).toBe(true)
438
+ expect(secondDef.fileCount).toBe(1)
439
+ expect(secondDef.files).toEqual([])
440
+ expect(setSpy).toHaveBeenCalledTimes(3)
441
+ } finally {
442
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
443
+ }
444
+ })
445
+
446
+ it('cache 命中值优先于磁盘(roots 不旁路缓存实扫——TTL/mtime 失效语义全在缓存实现侧)', async () => {
447
+ const { tmp, agentDir } = await buildMinimalFixture()
448
+ try {
449
+ const setSpy = vi.fn()
450
+ const cache = {
451
+ get: async () => ({ exists: true, fileCount: 99, scanMs: 7 }),
452
+ set: setSpy,
453
+ }
454
+ const roots = await resolveSessionRoots({ agentDir }, { cache })
455
+ const def = roots.find((r) => r.kind === 'default')!
456
+ expect(def.fileCount).toBe(99)
457
+ expect(def.scanMs).toBe(7)
458
+ expect(def.cached).toBe(true)
459
+ expect(def.files).toEqual([])
460
+ expect(setSpy).not.toHaveBeenCalled() // 命中不回写
461
+ } finally {
462
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
463
+ }
464
+ })
465
+
466
+ it("stat × cache 组合:'stat' 的 subagent 根不产缓存条目(缓存只服务扫盘贵的根)", async () => {
467
+ const { tmp, agentDir } = await buildMinimalFixture()
468
+ try {
469
+ const setKeys: string[] = []
470
+ await resolveSessionRoots({ agentDir }, {
471
+ subagents: 'stat',
472
+ cache: {
473
+ get: async () => undefined,
474
+ set: (key) => {
475
+ setKeys.push(key)
476
+ },
477
+ },
478
+ })
479
+ expect(setKeys).not.toContain(join(agentDir, 'subagents'))
480
+ expect(setKeys).toContain(join(agentDir, 'sessions'))
481
+ } finally {
482
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
483
+ }
484
+ })
132
485
  })
@@ -10,6 +10,7 @@ import {
10
10
  extractSessionIdFromFilename,
11
11
  type RecordManifest,
12
12
  } from '../discovery/subagents.js'
13
+ import { REAL_DATA_TIMEOUT_MS } from './real-data.js'
13
14
 
14
15
  // ---- fixture 常量(uuid 特征,满足 extractSessionIdFromFilename + 互不为子串)----
15
16
  const ROOT = '0aaaaaaa-bbbb-7ccc-dddd-000000000001'
@@ -471,7 +472,7 @@ describe.skipIf(!HAS_REAL_ROOT)('buildFamilyFromFs - 真实数据 ~/.pi/agent',
471
472
  )
472
473
  expect(sub).toBeDefined()
473
474
  expect(sub!.sessionId.startsWith('sa-')).toBe(false)
474
- }, 30000)
475
+ }, REAL_DATA_TIMEOUT_MS)
475
476
  })
476
477
 
477
478
  describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () => {
@@ -500,7 +501,7 @@ describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () =
500
501
  // stateFile 是 wf-state 文件绝对路径
501
502
  expect(rich!.stateFile.endsWith('.jsonl')).toBe(true)
502
503
  expect(rich!.runId.startsWith('wf-')).toBe(true)
503
- }, 30000)
504
+ }, REAL_DATA_TIMEOUT_MS)
504
505
  })
505
506
 
506
507
  // ============================================================
@@ -735,5 +736,5 @@ describe.skipIf(!HAS_REAL_ROOT || !HAS_REAL_SUBAGENTS_DIR)('U4 真实数据守
735
736
 
736
737
  // 所有 subagent 的 sessionFile 非空(manifest.sessionFile 或 alive meta.path)
737
738
  expect(family.subagents.every((s) => typeof s.sessionFile === 'string' && s.sessionFile.length > 0)).toBe(true)
738
- }, 30000)
739
+ }, REAL_DATA_TIMEOUT_MS)
739
740
  })