@zhushanwen/pi-session-reader 0.5.1 → 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.1",
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.10.0"
24
+ "@zhushanwen/subagent-core": "0.10.1"
22
25
  },
23
26
  "files": [
24
27
  "index.ts",
@@ -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
 
@@ -280,7 +281,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
280
281
  })
281
282
 
282
283
  // session-reader 侧:sa- id 反查(v1 此处报「无匹配 record」——manifest 惰性不落盘)
283
- const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, agentDir)
284
+ const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, { agentDir })
284
285
  // 同源语义:跨 assistant message "\n\n" join、thinking 块排除——与 record.result 逐字节一致
285
286
  expect(r.content[0]?.text).toBe(resultText)
286
287
  const d = r.details as {
@@ -304,7 +305,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
304
305
  ]
305
306
  await driveSyncBatchRecovery(members)
306
307
 
307
- 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 })
308
309
  const text = r.content[0]?.text ?? ''
309
310
  expect(text).toContain('[1/2] sa-cross-a')
310
311
  expect(text).toContain('[2/2] sa-cross-b')
@@ -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
 
@@ -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 })
@@ -2,11 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import {
6
- handleSessionRead,
7
- extractFinalAssistantText,
8
- type SessionReadParams,
9
- } from '../tool-handler.js'
5
+ import { handleSessionRead, type SessionReadParams } from '../tool-handler.js'
6
+ import { extractFinalAssistantText } from '../result-action.js'
10
7
 
11
8
  /**
12
9
  * U6 result action 测试(design subagent-sync-collect §3.1.3 + impl-plan U6)。
@@ -152,7 +149,7 @@ describe('result action(U6)', () => {
152
149
  assistantEntry(`${realId}-a2`, [{ type: 'text', text: 'second part' }]),
153
150
  ],
154
151
  })
155
- const r = await handleSessionRead({ action: 'result', session: 'sa-one' }, dir)
152
+ const r = await handleSessionRead({ action: 'result', session: 'sa-one' }, { agentDir: dir })
156
153
  // 与 record.result 同源:全部 assistant 正文非空过滤 '\n\n' join,无任何包装
157
154
  expect(r.content[0]?.text).toBe('FINAL RESULT\n\nsecond part')
158
155
  const d = r.details as {
@@ -184,7 +181,7 @@ describe('result action(U6)', () => {
184
181
  }
185
182
  const r = await handleSessionRead(
186
183
  { action: 'result', session: 'sa-b1,sa-b2,sa-b3' },
187
- dir,
184
+ { agentDir: dir },
188
185
  )
189
186
  const text = r.content[0]?.text ?? ''
190
187
  expect(text).toContain('[1/3] sa-b1')
@@ -207,10 +204,10 @@ describe('result action(U6)', () => {
207
204
  it('3. 批量 >10:11 id 拒绝,报错含上限 10 与分批指引(解析先于 fs,无需 fixture)', async () => {
208
205
  const list = Array.from({ length: 11 }, (_, i) => `sa-x${i}`).join(',')
209
206
  await expect(
210
- handleSessionRead({ action: 'result', session: list }, dir),
207
+ handleSessionRead({ action: 'result', session: list }, { agentDir: dir }),
211
208
  ).rejects.toThrow(/最多 10 个/)
212
209
  try {
213
- await handleSessionRead({ action: 'result', session: list }, dir)
210
+ await handleSessionRead({ action: 'result', session: list }, { agentDir: dir })
214
211
  } catch (e) {
215
212
  const msg = (e as Error).message
216
213
  expect(msg).toContain('11')
@@ -226,7 +223,7 @@ describe('result action(U6)', () => {
226
223
  realId,
227
224
  entries: [assistantEntry(`${realId}-a1`, [{ type: 'text', text: longText }])],
228
225
  })
229
- const r = await handleSessionRead({ action: 'result', session: 'sa-long' }, dir)
226
+ const r = await handleSessionRead({ action: 'result', session: 'sa-long' }, { agentDir: dir })
230
227
  const text = r.content[0]?.text ?? ''
231
228
  // 截断到 8000:第 8001 个 x 不在正文,但提示行给出计数
232
229
  expect(text.startsWith('x'.repeat(8000))).toBe(true)
@@ -249,7 +246,7 @@ describe('result action(U6)', () => {
249
246
  })
250
247
  const cut = await handleSessionRead(
251
248
  { action: 'result', session: 'sa-lim', limit: 5 },
252
- dir,
249
+ { agentDir: dir },
253
250
  )
254
251
  expect(cut.content[0]?.text).toBe(
255
252
  'abcde\n\n[truncated 5 of 10 chars — full text: read ' +
@@ -258,31 +255,31 @@ describe('result action(U6)', () => {
258
255
  )
259
256
  const exact = await handleSessionRead(
260
257
  { action: 'result', session: 'sa-lim', limit: 10 },
261
- dir,
258
+ { agentDir: dir },
262
259
  )
263
260
  expect(exact.content[0]?.text).toBe('abcdefghij')
264
261
  const d = exact.details as { truncated: boolean }
265
262
  expect(d.truncated).toBe(false)
266
263
  for (const bad of [0, -1, Number.NaN]) {
267
264
  await expect(
268
- handleSessionRead({ action: 'result', session: 'sa-lim', limit: bad }, dir),
265
+ handleSessionRead({ action: 'result', session: 'sa-lim', limit: bad }, { agentDir: dir }),
269
266
  ).rejects.toThrow(/limit.*无效/)
270
267
  }
271
268
  })
272
269
 
273
270
  it('6. 不存在 id:sa-id 无 manifest → 无匹配 record;uuid 片段零匹配 → 无匹配 session', async () => {
274
271
  await expect(
275
- handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, dir),
272
+ handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir }),
276
273
  ).rejects.toThrow(/无匹配 record/)
277
274
  try {
278
- await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, dir)
275
+ await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir })
279
276
  } catch (e) {
280
277
  const msg = (e as Error).message
281
278
  expect(msg).toContain('👉')
282
279
  expect(msg).toContain('family')
283
280
  }
284
281
  await expect(
285
- handleSessionRead({ action: 'result', session: 'zzz-nonexistent-9q8x2' }, dir),
282
+ handleSessionRead({ action: 'result', session: 'zzz-nonexistent-9q8x2' }, { agentDir: dir }),
286
283
  ).rejects.toThrow(/无匹配 session/)
287
284
  })
288
285
 
@@ -293,10 +290,10 @@ describe('result action(U6)', () => {
293
290
  sessionFileExists: false,
294
291
  })
295
292
  await expect(
296
- handleSessionRead({ action: 'result', session: 'sa-flush' }, dir),
293
+ handleSessionRead({ action: 'result', session: 'sa-flush' }, { agentDir: dir }),
297
294
  ).rejects.toThrow(/session 文件不存在/)
298
295
  try {
299
- await handleSessionRead({ action: 'result', session: 'sa-flush' }, dir)
296
+ await handleSessionRead({ action: 'result', session: 'sa-flush' }, { agentDir: dir })
300
297
  } catch (e) {
301
298
  const msg = (e as Error).message
302
299
  expect(msg).toContain('未写入')
@@ -310,10 +307,10 @@ describe('result action(U6)', () => {
310
307
  realId: '019e6c96-0000-0000-0000-000000000a01',
311
308
  })
312
309
  await expect(
313
- handleSessionRead({ action: 'result', session: 'sa-empty' }, dir),
310
+ handleSessionRead({ action: 'result', session: 'sa-empty' }, { agentDir: dir }),
314
311
  ).rejects.toThrow(/尚无 assistant 输出/)
315
312
  try {
316
- await handleSessionRead({ action: 'result', session: 'sa-empty' }, dir)
313
+ await handleSessionRead({ action: 'result', session: 'sa-empty' }, { agentDir: dir })
317
314
  } catch (e) {
318
315
  const msg = (e as Error).message
319
316
  expect(msg).toContain('sa-empty')
@@ -323,10 +320,10 @@ describe('result action(U6)', () => {
323
320
 
324
321
  it('9. 参数防御:缺 session / 空条目(尾逗号)均报错', async () => {
325
322
  await expect(
326
- handleSessionRead({ action: 'result' } as SessionReadParams, dir),
323
+ handleSessionRead({ action: 'result' } as SessionReadParams, { agentDir: dir }),
327
324
  ).rejects.toThrow(/需要参数 "session"/)
328
325
  await expect(
329
- handleSessionRead({ action: 'result', session: 'sa-a,' }, dir),
326
+ handleSessionRead({ action: 'result', session: 'sa-a,' }, { agentDir: dir }),
330
327
  ).rejects.toThrow(/空条目/)
331
328
  })
332
329
 
@@ -334,7 +331,7 @@ describe('result action(U6)', () => {
334
331
  await expect(
335
332
  handleSessionRead(
336
333
  { action: 'bogus' } as unknown as SessionReadParams,
337
- dir,
334
+ { agentDir: dir },
338
335
  ),
339
336
  ).rejects.toThrow(/find\/family\/outline\/expand\/detail\/search\/export\/extract\/workflow\/result\/doctor/)
340
337
  })
@@ -1,10 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { mkdtemp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
4
4
  import { join, dirname } from 'node:path'
5
5
  import {
6
- listMainSessions,
7
- listSubagentSessions,
8
6
  resolveSessionRoots,
9
7
  normalizeLiveSessionDir,
10
8
  type SessionRoot,
@@ -13,6 +11,24 @@ import {
13
11
  import { buildFamilyFromFs } from '../discovery/subagents.js'
14
12
  import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_AGENT_DIR, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
15
13
 
14
+ // ============================================================
15
+ // A3(ext-simplify-04 U4):listMainSessions/listSubagentSessions 薄包装已删除,
16
+ // subagents.ts 直调 resolveSessionRoots+filter。以下 describe 用同一等价式作入口,
17
+ // 保过滤语义覆盖(.finalized 排除 / workflow-state 跳过 / 递归 / 根选择 / 空目录容错)。
18
+ // ============================================================
19
+
20
+ /** 原 listMainSessions 等价式:agentDir 信号包下未去重 main 根的文件并集 */
21
+ async function mainSessionFiles(agentDir: string) {
22
+ return (await resolveSessionRoots({ agentDir }))
23
+ .filter((r) => r.source === 'main' && r.dedupedInto === undefined)
24
+ .flatMap((r) => r.files)
25
+ }
26
+
27
+ /** 原 listSubagentSessions 等价式:agentDir 信号包下 [subagent] 根文件集 */
28
+ async function subagentSessionFiles(agentDir: string) {
29
+ return (await resolveSessionRoots({ agentDir })).find((r) => r.kind === 'subagent')?.files ?? []
30
+ }
31
+
16
32
  /** 写 .jsonl fixture(父目录不存在则自建),内容默认合法 session header */
17
33
  async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n') {
18
34
  await mkdir(dirname(path), { recursive: true })
@@ -22,7 +38,7 @@ async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n
22
38
  /** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾,实证见 real-data.ts) */
23
39
  const SLUG = '--Users-foo--'
24
40
 
25
- describe('listMainSessions', () => {
41
+ describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆盖)', () => {
26
42
  let dir: string
27
43
 
28
44
  beforeEach(async () => {
@@ -38,7 +54,7 @@ describe('listMainSessions', () => {
38
54
  await writeFile(join(dir, 'sessions', slug, 'a.jsonl'), '{"type":"session","id":"a"}\n')
39
55
  await writeFile(join(dir, 'sessions', slug, 'b.jsonl.finalized'), '{"type":"session","id":"b"}\n')
40
56
 
41
- const result = await listMainSessions(dir)
57
+ const result = await mainSessionFiles(dir)
42
58
  expect(result).toHaveLength(1)
43
59
  expect(result[0].path.endsWith('a.jsonl')).toBe(true)
44
60
  expect(result[0].path.endsWith('.finalized')).toBe(false)
@@ -54,7 +70,7 @@ describe('listMainSessions', () => {
54
70
  // 同 slug 直接层也放一个,验证同一 slug 下平铺与嵌套并存
55
71
  await writeFile(join(dir, 'sessions', 'slug', 'top.jsonl'), '{}\n')
56
72
 
57
- const result = await listMainSessions(dir)
73
+ const result = await mainSessionFiles(dir)
58
74
  const names = result.map((m) => m.path.split('/').pop()!)
59
75
  expect(names).toContain('d.jsonl')
60
76
  expect(names).toContain('top.jsonl')
@@ -70,22 +86,22 @@ describe('listMainSessions', () => {
70
86
  '{"v":"wf-run-v1"}\n',
71
87
  )
72
88
 
73
- const result = await listMainSessions(dir)
89
+ const result = await mainSessionFiles(dir)
74
90
  expect(result).toHaveLength(1)
75
91
  expect(result[0].path.endsWith('real.jsonl')).toBe(true)
76
92
  expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
77
93
  })
78
94
 
79
95
  it('空 agentDir(无 sessions 目录)返回 [],不抛错', async () => {
80
- await expect(listMainSessions(dir)).resolves.toEqual([])
96
+ await expect(mainSessionFiles(dir)).resolves.toEqual([])
81
97
  })
82
98
 
83
99
  it('不存在的 agentDir 返回 [],不抛错', async () => {
84
- await expect(listMainSessions(join(dir, 'no-such-dir'))).resolves.toEqual([])
100
+ await expect(mainSessionFiles(join(dir, 'no-such-dir'))).resolves.toEqual([])
85
101
  })
86
102
 
87
103
  it.skipIf(!HAS_E6)('真实数据:扫描 ~/.pi/agent,含 019e6c96,不含 .finalized 与 wf-', async () => {
88
- const result = await listMainSessions(REAL_AGENT_DIR)
104
+ const result = await mainSessionFiles(REAL_AGENT_DIR)
89
105
  expect(result.length).toBeGreaterThan(0)
90
106
  // 含目标 session
91
107
  expect(result.some((m) => m.path.includes('019e6c96'))).toBe(true)
@@ -100,7 +116,7 @@ describe('listMainSessions', () => {
100
116
  }, REAL_DATA_TIMEOUT_MS)
101
117
  })
102
118
 
103
- describe('listSubagentSessions', () => {
119
+ describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后保覆盖)', () => {
104
120
  let dir: string
105
121
 
106
122
  beforeEach(async () => {
@@ -119,7 +135,7 @@ describe('listSubagentSessions', () => {
119
135
  '{"type":"session"}\n',
120
136
  )
121
137
 
122
- const result = await listSubagentSessions(dir)
138
+ const result = await subagentSessionFiles(dir)
123
139
  expect(result).toHaveLength(1)
124
140
  expect(result[0].path.endsWith('c.jsonl')).toBe(true)
125
141
  expect(result[0].path.endsWith('.finalized')).toBe(false)
@@ -132,17 +148,17 @@ describe('listSubagentSessions', () => {
132
148
  await writeFile(join(dir, 'subagents', slug, 'records', 'manifest.json'), '{}\n')
133
149
  await writeFile(join(dir, 'subagents', slug, 'sessions', 'sub.jsonl'), '{"type":"session"}\n')
134
150
 
135
- const result = await listSubagentSessions(dir)
151
+ const result = await subagentSessionFiles(dir)
136
152
  expect(result).toHaveLength(1)
137
153
  expect(result[0].path.endsWith('sub.jsonl')).toBe(true)
138
154
  })
139
155
 
140
156
  it('无 subagents 目录返回 [],不抛错', async () => {
141
- await expect(listSubagentSessions(dir)).resolves.toEqual([])
157
+ await expect(subagentSessionFiles(dir)).resolves.toEqual([])
142
158
  })
143
159
 
144
160
  it.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:扫描 ~/.pi/agent/subagents 返回非空', async () => {
145
- const result = await listSubagentSessions(REAL_AGENT_DIR)
161
+ const result = await subagentSessionFiles(REAL_AGENT_DIR)
146
162
  expect(result.length).toBeGreaterThan(0)
147
163
  expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
148
164
  }, REAL_DATA_TIMEOUT_MS)
@@ -364,11 +380,11 @@ describe('buildFamilyFromFs not-found 文案(U1 并入:列实际扫描候选
364
380
 
365
381
  // ============================================================
366
382
  // u8 追加:resolveSessionRoots options(doctor 统一数据源,design §6.3「同一数据源
367
- // 两处渲染,不重复实现探测」——subagent 扫描模式与进程内缓存经 options 由调用方
368
- // 驱动,本模块不内置 TTL/mtime 失效逻辑)
383
+ // 两处渲染,不重复实现探测」——subagent 扫描模式经 options 由调用方驱动;原 u8 的
384
+ // 注入式缓存面已删除,ext-simplify-04 U3,每次调用实扫)
369
385
  // ============================================================
370
386
 
371
- describe('resolveSessionRoots options(u8:subagents 模式 + 注入式缓存)', () => {
387
+ describe('resolveSessionRoots options(u8:subagents 模式)', () => {
372
388
  /** 最小 fixture:default 根 1 文件 + subagent 根 1 文件(legacy 不存在) */
373
389
  async function buildMinimalFixture(): Promise<{ tmp: string; agentDir: string }> {
374
390
  const tmp = await mkdtemp(join(tmpdir(), 'roots-opts-'))
@@ -413,71 +429,21 @@ describe('resolveSessionRoots options(u8:subagents 模式 + 注入式缓存
413
429
  }
414
430
  })
415
431
 
416
- it('cache:未命中实扫后 set 回写;命中以缓存值构造根(cached 标记、files 空、不再回写)', async () => {
432
+ it('重复调用每次实扫(无缓存):两次结果独立完整,files 恒非空实扫值', async () => {
433
+ // 原三个 cache 用例(set 回写 / 命中优先 / stat × cache)随缓存注入面删除
434
+ // (ext-simplify-04 U3);本用例钉死删除后契约——不存在任何缓存层
417
435
  const { tmp, agentDir } = await buildMinimalFixture()
418
436
  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,
437
+ const first = await resolveSessionRoots({ agentDir })
438
+ const second = await resolveSessionRoots({ agentDir })
439
+ for (const roots of [first, second]) {
440
+ const def = roots.find((r) => r.kind === 'default')!
441
+ expect(def.fileCount).toBe(1)
442
+ expect(def.files).toHaveLength(1)
443
+ const sub = roots.find((r) => r.kind === 'subagent')!
444
+ expect(sub.fileCount).toBe(1)
445
+ expect(sub.files).toHaveLength(1)
453
446
  }
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
447
  } finally {
482
448
  await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
483
449
  }
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { createSessionCommand } from '../tui/session-command.js'
6
+ import { SessionManager } from '@earendil-works/pi-coding-agent'
6
7
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
7
8
 
8
9
  /**
@@ -117,6 +118,16 @@ describe('createSessionCommand - getArgumentCompletions', () => {
117
118
  const cmd = createSessionCommand(() => cwdSessionDir)
118
119
  expect(await cmd.getArgumentCompletions('deadbeef')).toBeNull()
119
120
  })
121
+
122
+ it('空 getCwdSessionDir → 秒回 null,不调 listAll(A1:无 live session 上下文不触发全盘分支)', async () => {
123
+ // mock 拦断言用:guard 失效误调 listAll('') 时 pi 会走真实全盘扫描(实测 3488 项 / ~8s),
124
+ // mock 掉保证失败态也是秒级红灯
125
+ const listAllSpy = vi.spyOn(SessionManager, 'listAll').mockResolvedValue([])
126
+ const cmd = createSessionCommand(() => '')
127
+ expect(await cmd.getArgumentCompletions('')).toBeNull()
128
+ expect(listAllSpy).not.toHaveBeenCalled()
129
+ listAllSpy.mockRestore()
130
+ })
120
131
  })
121
132
 
122
133
  describe('createSessionCommand - handler select 流程', () => {
@@ -175,6 +186,18 @@ describe('createSessionCommand - handler select 流程', () => {
175
186
  expect(setEditorText).not.toHaveBeenCalled()
176
187
  })
177
188
 
189
+ it('空 getCwdSessionDir → 按零匹配 notify,不调 listAll(A1:handler 侧同款空串 guard)', async () => {
190
+ const listAllSpy = vi.spyOn(SessionManager, 'listAll').mockResolvedValue([])
191
+ const cmd = createSessionCommand(() => '')
192
+ const { ctx, select, notify, setEditorText } = makeFakeCtx()
193
+ await cmd.handler('', ctx)
194
+ expect(notify).toHaveBeenCalledWith('未找到匹配的 session。', 'warning')
195
+ expect(select).not.toHaveBeenCalled()
196
+ expect(setEditorText).not.toHaveBeenCalled()
197
+ expect(listAllSpy).not.toHaveBeenCalled()
198
+ listAllSpy.mockRestore()
199
+ })
200
+
178
201
  it('MF-2 回归:同 cwd 同预览同 age 桶 → label 带短 uuid 后缀消歧,选第 2 条插第 2 条 uuid', async () => {
179
202
  // 两 session:相同首消息 + 相同 timestamp(同 age 桶)→ toCandidate label 完全相同
180
203
  const ID1 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
@@ -228,9 +228,10 @@ describe('buildFamilyFromFs - fixture', () => {
228
228
  expect(sub!.rootSessionId).toBe(FORK)
229
229
  expect(sub!.slug).toBe('test-sub')
230
230
  expect(sub!.cleanedUp).toBe(false)
231
- // enrich:fileName/cwd 已补真实值(非 M1 占位空串)
232
- expect(sub!.fileName.length).toBeGreaterThan(0)
233
- expect(sub!.cwd).toBe('/proj/--fork-cwd--')
231
+ // enrichRefs 已删除(ext-simplify-04 U4/E2):family 路径 fileName/cwd 维持 core 占位空串
232
+ //(SessionRef 与 find 路径共享,find 侧有真实消费者;workflow calls 反查走 pathToRef 不受影响)
233
+ expect(sub!.fileName).toBe('')
234
+ expect(sub!.cwd).toBe('')
234
235
  })
235
236
 
236
237
  it('从 fork 子代 resolve 也能关联到挂在其下的 subagent', async () => {