@zhushanwen/pi-session-reader 0.5.1 → 0.6.1

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,23 +1,24 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { existsSync } from 'node:fs'
3
- import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, writeFile, rm, utimes, readFile } from 'node:fs/promises'
4
4
  import { tmpdir, homedir } from 'node:os'
5
5
  import { join, dirname } from 'node:path'
6
6
  import {
7
7
  handleSessionRead,
8
- levenshtein,
9
- renderExtractItems,
10
- DOCTOR_CACHE_TTL_MS,
11
8
  METADATA_CACHE_TTL_MS,
12
- MULTI_SEARCH_MAX_SESSIONS,
13
- SEARCH_SCAN_BYTE_BUDGET,
14
- searchAcrossSessions,
15
9
  type SessionReadParams,
16
10
  type SessionReadSignals,
17
11
  } from '../tool-handler.js'
12
+ import { type DoctorDetails } from '../doctor.js'
13
+ import { levenshtein } from '../no-match.js'
14
+ import {
15
+ MULTI_SEARCH_MAX_SESSIONS,
16
+ SEARCH_SCAN_BYTE_BUDGET,
17
+ searchAcrossSessions,
18
+ } from '../search-across.js'
19
+ import { renderExtractItems } from '../extract.js'
18
20
  import { listRecordManifests } from '../discovery/subagents.js'
19
21
  import type { SessionMetadataProvider } from '../discovery/find.js'
20
- import type { DoctorDetails } from '../doctor.js'
21
22
  import {
22
23
  REAL_AGENT_DIR as REAL,
23
24
  E6,
@@ -29,11 +30,20 @@ import {
29
30
  hasRealSession,
30
31
  } from './real-data.js'
31
32
 
33
+ // fs-guard:TC6 的「~ 前缀展开」语义需要 homedir,但真实 homedir 根在白名单外(禁止向用户
34
+ // 主目录散落文件)。vi.mock 把 homedir 指到用例内 mkdtemp 的 tmp;fakeHome 未注入时回落真实值,
35
+ // real-data.ts 模块初始化时推导 REAL_AGENT_DIR 不受影响(模块初始化先于任何用例执行)。
36
+ const homeState = vi.hoisted(() => ({ fakeHome: undefined as string | undefined }))
37
+ vi.mock('node:os', async (importOriginal) => {
38
+ const actual = await importOriginal<typeof import('node:os')>()
39
+ return { ...actual, homedir: () => homeState.fakeHome ?? actual.homedir() }
40
+ })
41
+
32
42
  /**
33
43
  * M3 tool-handler 集成测试。
34
44
  *
35
45
  * 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
36
- * 传真实 `/Users/zhushanwen/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
46
+ * 传真实 `~/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
37
47
  *
38
48
  * 覆盖全 11 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价,
39
49
  * 以及 u8 doctor / u9 归一化 / u10 find 分组 / u11 metadataProvider 缓存 / u12 跨会话检索(各见对应 describe)。
@@ -46,14 +56,14 @@ import {
46
56
  // vitest 默认 5s(pnpm extensions:test 全量跑时多包集成测试并发 IO),显式放宽到 60s。
47
57
  describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
48
58
  it('1. find by uuid fragment returns matching session', async () => {
49
- const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, REAL)
59
+ const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir: REAL })
50
60
  const d = r.details as { matches: Array<{ sessionId: string }> }
51
61
  expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
52
62
  expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
53
63
  })
54
64
 
55
65
  it('2. outline yields 32 turns within token budget (v2 O1: <=1500)', async () => {
56
- const r = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
66
+ const r = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
57
67
  const d = r.details as { turns: unknown[]; tokenEstimate: number }
58
68
  expect(d.turns.length).toBe(32)
59
69
  // v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 600→1500(design §3.3 D4)
@@ -61,7 +71,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
61
71
  })
62
72
 
63
73
  it('3. detail single turn returns toolResult summary by default (v2 O3)', async () => {
64
- const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, REAL)
74
+ const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, { agentDir: REAL })
65
75
  const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
66
76
  expect(d.entries.length).toBeGreaterThan(0)
67
77
  // v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
@@ -76,7 +86,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
76
86
  // 而非失败——与 TC14-TC18 的「数据不存在则 return」守卫模式一致,避免偶发红。
77
87
  // 注意必须用 hasAnyRealSession(双目录),hasRealSession 只扫 sessions/ 扫不到 019fe635。
78
88
  if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
79
- const r = await handleSessionRead({ action: 'family', session: FAM }, REAL)
89
+ const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: REAL })
80
90
  const d = r.details as {
81
91
  forks: Array<{ sessionId: string }>
82
92
  subagents: Array<{ sessionId: string }>
@@ -87,21 +97,28 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
87
97
  })
88
98
 
89
99
  it('5. search pattern returns hits', async () => {
90
- const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, REAL)
100
+ const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, { agentDir: REAL })
91
101
  const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
92
102
  expect(d.hits.length).toBeGreaterThan(0)
93
103
  expect(typeof d.hits[0].matchSnippet).toBe('string')
94
104
  })
95
105
 
96
106
  it('6. export outline materializes a .md file', async () => {
97
- const r = await handleSessionRead(
98
- { action: 'export', session: E6, format: 'outline' },
99
- REAL,
100
- )
101
- const d = r.details as { path: string; sizeBytes: number }
102
- expect(d.path).toMatch(/\.md$/)
103
- expect(existsSync(d.path)).toBe(true)
104
- expect(d.sizeBytes).toBeGreaterThan(0)
107
+ // fs-guard:export 写目标 = <agentDir>/tmp/,agentDir 传 REAL 会 mkdir 真实 ~/.pi/agent/tmp
108
+ // (白名单外)。读侧经 liveSessionDir=[live] 根仍扫真实数据,写侧落 mkdtemp tmp 自建自删。
109
+ const dir = await mkdtemp(join(tmpdir(), 'tool-handler-export-'))
110
+ try {
111
+ const r = await handleSessionRead(
112
+ { action: 'export', session: E6, format: 'outline' },
113
+ { agentDir: dir, liveSessionDir: join(REAL, 'sessions') },
114
+ )
115
+ const d = r.details as { path: string; sizeBytes: number }
116
+ expect(d.path).toMatch(/\.md$/)
117
+ expect(existsSync(d.path)).toBe(true)
118
+ expect(d.sizeBytes).toBeGreaterThan(0)
119
+ } finally {
120
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
121
+ }
105
122
  })
106
123
 
107
124
  // w1 合并 subagent 候选后,本机 subagent task 文本(含本用例 query 字符串本身,因当前
@@ -111,7 +128,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
111
128
  it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
112
129
  const r = await handleSessionRead(
113
130
  { action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
114
- REAL,
131
+ { agentDir: REAL },
115
132
  )
116
133
  const d = r.details as { matches: unknown[]; truncated: boolean }
117
134
  expect(d.matches).toEqual([])
@@ -125,17 +142,17 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
125
142
 
126
143
  it('8. F4 detail turn out of range throws with 越界', async () => {
127
144
  await expect(
128
- handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, REAL),
145
+ handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, { agentDir: REAL }),
129
146
  ).rejects.toThrow(/越界/)
130
147
  })
131
148
 
132
149
  it('9. F5 outline missing session throws naming session', async () => {
133
- await expect(handleSessionRead({ action: 'outline' }, REAL)).rejects.toThrow(/session/)
150
+ await expect(handleSessionRead({ action: 'outline' }, { agentDir: REAL })).rejects.toThrow(/session/)
134
151
  })
135
152
 
136
153
  it('10. resolveSessionId fragment equivalent to full id', async () => {
137
- const rFull = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
138
- const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, REAL)
154
+ const rFull = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
155
+ const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: REAL })
139
156
  const dFull = rFull.details as { turns: unknown[] }
140
157
  const dFrag = rFrag.details as { turns: unknown[] }
141
158
  expect(dFrag.turns.length).toBe(dFull.turns.length)
@@ -146,7 +163,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
146
163
  it('user-messages returns 26 user entries with turn + full text', async () => {
147
164
  const r = await handleSessionRead(
148
165
  { action: 'extract', session: E6, what: 'user-messages' },
149
- REAL,
166
+ { agentDir: REAL },
150
167
  )
151
168
  const d = r.details as {
152
169
  what: string
@@ -169,7 +186,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
169
186
  it('commands (no filter) returns 519 tool calls with name + summary', async () => {
170
187
  const r = await handleSessionRead(
171
188
  { action: 'extract', session: E6, what: 'commands' },
172
- REAL,
189
+ { agentDir: REAL },
173
190
  )
174
191
  const d = r.details as {
175
192
  count: number
@@ -192,7 +209,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
192
209
  it('commands tool=bash returns 309 all bash', async () => {
193
210
  const r = await handleSessionRead(
194
211
  { action: 'extract', session: E6, what: 'commands', tool: 'bash' },
195
- REAL,
212
+ { agentDir: REAL },
196
213
  )
197
214
  const d = r.details as {
198
215
  count: number
@@ -206,7 +223,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
206
223
  it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
207
224
  const r = await handleSessionRead(
208
225
  { action: 'extract', session: E6, what: 'commands', tool: 'nonexist' },
209
- REAL,
226
+ { agentDir: REAL },
210
227
  )
211
228
  const d = r.details as {
212
229
  toolDistribution: Array<{ name: string; count: number }>
@@ -220,7 +237,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
220
237
  it('files returns deduped paths with op (read/edit/write/head)', async () => {
221
238
  const r = await handleSessionRead(
222
239
  { action: 'extract', session: E6, what: 'files' },
223
- REAL,
240
+ { agentDir: REAL },
224
241
  )
225
242
  const d = r.details as {
226
243
  count: number
@@ -241,7 +258,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
241
258
  it('commits returns hash list with 7-8 hex + source turn', async () => {
242
259
  const r = await handleSessionRead(
243
260
  { action: 'extract', session: E6, what: 'commits' },
244
- REAL,
261
+ { agentDir: REAL },
245
262
  )
246
263
  const d = r.details as {
247
264
  count: number
@@ -260,7 +277,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
260
277
  it('tool-results (no filter) returns 515 results with toolName + text', async () => {
261
278
  const r = await handleSessionRead(
262
279
  { action: 'extract', session: E6, what: 'tool-results' },
263
- REAL,
280
+ { agentDir: REAL },
264
281
  )
265
282
  const d = r.details as {
266
283
  count: number
@@ -277,7 +294,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
277
294
  it('tool-results tool=bash returns 309 all bash', async () => {
278
295
  const r = await handleSessionRead(
279
296
  { action: 'extract', session: E6, what: 'tool-results', tool: 'bash' },
280
- REAL,
297
+ { agentDir: REAL },
281
298
  )
282
299
  const d = r.details as { count: number; items: Array<{ toolName: string }> }
283
300
  expect(d.count).toBe(309)
@@ -287,7 +304,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
287
304
  it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
288
305
  const r = await handleSessionRead(
289
306
  { action: 'extract', session: E6, what: 'tool-results', tool: 'nonexist' },
290
- REAL,
307
+ { agentDir: REAL },
291
308
  )
292
309
  expect(r.content[0].text).toContain('无匹配')
293
310
  expect(r.content[0].text).toContain('bash×309')
@@ -297,8 +314,8 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
297
314
  // what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
298
315
  // isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
299
316
  const params: SessionReadParams = { action: 'extract', session: E6 }
300
- await expect(handleSessionRead(params, REAL)).rejects.toThrow(/无效/)
301
- await expect(handleSessionRead(params, REAL)).rejects.toThrow(
317
+ await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(/无效/)
318
+ await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(
302
319
  /user-messages\/commands\/files\/commits\/tool-results/,
303
320
  )
304
321
  })
@@ -309,7 +326,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
309
326
  try {
310
327
  await handleSessionRead(
311
328
  { action: 'extract', session: E6, what: 'user-messages', turns: 'T999' },
312
- REAL,
329
+ { agentDir: REAL },
313
330
  )
314
331
  } catch (e) {
315
332
  msg = (e as Error).message
@@ -358,7 +375,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
358
375
  await makeFixtureSession(dir, ID2, '第二段内容')
359
376
 
360
377
  // outline 走 resolveSessionId → 2 匹配 → F2 消歧(不抛错)
361
- const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, dir)
378
+ const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, { agentDir: dir })
362
379
  const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
363
380
  expect(d.ambiguous).toBe(true)
364
381
  expect(d.candidates).toHaveLength(2)
@@ -382,7 +399,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
382
399
  if (action === 'expand') params.turn = 'T001'
383
400
  if (action === 'search') params.pattern = 'x'
384
401
  if (action === 'extract') params.what = 'user-messages'
385
- const r = await handleSessionRead(params, dir)
402
+ const r = await handleSessionRead(params, { agentDir: dir })
386
403
  const d = r.details as { ambiguous: boolean }
387
404
  expect(d.ambiguous, `action=${action}`).toBe(true)
388
405
  expect(r.content[0].text).toContain('👉')
@@ -417,7 +434,7 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
417
434
  ]
418
435
  await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
419
436
 
420
- const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
437
+ const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
421
438
  const d = r.details as { stats: { skippedLines: number } }
422
439
  expect(d.stats.skippedLines).toBe(1)
423
440
  expect(r.content[0].text).toContain('1 skipped lines')
@@ -425,13 +442,84 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
425
442
 
426
443
  it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
427
444
  await makeFixtureSession(dir, SID, 'clean session')
428
- const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
445
+ const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
429
446
  const d = r.details as { stats: { skippedLines: number } }
430
447
  expect(d.stats.skippedLines).toBe(0)
431
448
  expect(r.content[0].text).not.toContain('skipped lines')
432
449
  })
433
450
  })
434
451
 
452
+ // ============================================================
453
+ // E7:行渲染统一(ext-simplify-04 U5,design D3)。outline 与 export {format:"outline"}
454
+ // 共用 renderOutline 返回的渲染行(OutlineResult.lines)+ 同一尾段拼装——预算度量 =
455
+ // 展示 by construction;旁支标记统一为 `[旁支 N entries]`(改前 handler 侧第二份行
456
+ // 格式知识输出裸 `[旁支]`,已随 formatOutlineText 删除)。
457
+ // ============================================================
458
+ describe('E7 行渲染统一(outline 与 export outline 同一渲染行)', () => {
459
+ let dir: string
460
+ const SID = '019e6c96-bbbb-cccc-dddd-00000000000e'
461
+
462
+ /** 写含 fork 旁支的 session:主链 R→m1(user)→m2(assistant),b1/b2 挂 m1 下成
463
+ * 旁支子树(buildTreeView:branches[m1]=2;segmentTurns:单 turn [m1, m2],
464
+ * 旁支 entry 不进 leaf 视图)。 */
465
+ async function writeForkSession(): Promise<void> {
466
+ await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
467
+ const msg = (id: string, parentId: string, role: 'user' | 'assistant', text: string) =>
468
+ JSON.stringify({
469
+ type: 'message',
470
+ id: `${SID}-${id}`,
471
+ parentId,
472
+ message: { role, content: [{ type: 'text', text }] },
473
+ })
474
+ const lines = [
475
+ JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
476
+ msg('m1', SID, 'user', 'fork point turn'),
477
+ msg('b1', `${SID}-m1`, 'assistant', 'branch one'),
478
+ msg('b2', `${SID}-m1`, 'assistant', 'branch two'),
479
+ msg('m2', `${SID}-m1`, 'assistant', 'main line answer'),
480
+ ]
481
+ await writeFile(join(dir, 'sessions', '--demo-cwd--', `${SID}.jsonl`), lines.join('\n') + '\n')
482
+ }
483
+
484
+ beforeEach(async () => {
485
+ dir = await mkdtemp(join(tmpdir(), 'tool-handler-e7-'))
486
+ })
487
+ afterEach(async () => {
488
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
489
+ })
490
+
491
+ it('① allBranches:true → 旁支行标记 `[旁支 N entries]`(N=forkPoint 旁支子树 entry 数)+ 尾段同改前语义', async () => {
492
+ await writeForkSession()
493
+ const r = await handleSessionRead(
494
+ { action: 'outline', session: SID, allBranches: true },
495
+ { agentDir: dir },
496
+ )
497
+ const text = r.content[0].text
498
+ // 旁支标记带 entry 数;裸 `[旁支]` 旧形态零出现('[旁支]' 与 '[旁支 2 entries]' 互不为子串)
499
+ expect(text).toContain('T000 · fork point turn · → main line answer · [旁支 2 entries]')
500
+ expect(text).not.toContain('[旁支]')
501
+ // stats 尾段与改前语义一致(totalEntries = leaf 2 + 旁支 2)
502
+ expect(text).toContain('1 turns · 4 entries · ')
503
+ })
504
+
505
+ it('② 两 action 一致性:export {format:"outline"} 物化全文 === outline content 文本(旁支行同形态)', async () => {
506
+ await writeForkSession()
507
+ const outline = await handleSessionRead(
508
+ { action: 'outline', session: SID, allBranches: true },
509
+ { agentDir: dir },
510
+ )
511
+ const exp = await handleSessionRead(
512
+ { action: 'export', session: SID, format: 'outline', allBranches: true },
513
+ { agentDir: dir },
514
+ )
515
+ const d = exp.details as { path: string }
516
+ const exported = await readFile(d.path, 'utf8')
517
+ // 干净 session(无坏行)下两 action 全文逐字节一致——同一 result.lines + 同一尾段拼装
518
+ expect(exported).toBe(outline.content[0].text)
519
+ expect(exported).toContain('[旁支 2 entries]')
520
+ })
521
+ })
522
+
435
523
  describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
436
524
  let dir: string
437
525
  const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
@@ -445,21 +533,21 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
445
533
  })
446
534
 
447
535
  it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
448
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
536
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
449
537
  const d = r.details as { hits: unknown[] }
450
538
  // 字面量 '(a+)+' 不在内容里 → 0 命中(若按正则执行会命中 'aaa' 且可能指数回溯)
451
539
  expect(d.hits).toHaveLength(0)
452
540
  })
453
541
 
454
542
  it('普通正则仍按正则匹配', async () => {
455
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, dir)
543
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, { agentDir: dir })
456
544
  const d = r.details as { hits: unknown[] }
457
545
  expect(d.hits.length).toBeGreaterThan(0)
458
546
  })
459
547
 
460
548
  it('范围量词 {m,n} 形态 → 降级字面子串((a{1,3})*、(a{0,2})*、(a{1,3}){2,},MF-1 回归)', async () => {
461
549
  for (const pattern of ['(a{1,3})*', '(a{0,2})*', '(a{1,3}){2,}']) {
462
- const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
550
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
463
551
  const d = r.details as { hits: unknown[] }
464
552
  // 字面量不含这些 pattern → 0 命中;若按正则执行会指数回溯挂死
465
553
  expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
@@ -468,7 +556,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
468
556
 
469
557
  it('alternation/嵌套量词分支 (a|aa)+、(a*)* → 降级字面子串(MF-5 分支回归)', async () => {
470
558
  for (const pattern of ['(a|aa)+', '(a*)*']) {
471
- const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
559
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
472
560
  const d = r.details as { hits: unknown[] }
473
561
  expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
474
562
  }
@@ -477,7 +565,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
477
565
  it('(a{1,3}) 单独使用不被降级(组后无尾随量词,仍按正则执行)', async () => {
478
566
  const r = await handleSessionRead(
479
567
  { action: 'search', session: SID, pattern: '(a{1,3})' },
480
- dir,
568
+ { agentDir: dir },
481
569
  )
482
570
  const d = r.details as { hits: unknown[] }
483
571
  // 按正则执行命中 'aaa' → >0 命中(若被降级为字面量则 0 命中)
@@ -485,12 +573,12 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
485
573
  })
486
574
 
487
575
  it('降级标注:header 含「已降级为字面子串匹配」(S-3)', async () => {
488
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
576
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
489
577
  expect(r.content[0].text).toContain('已降级为字面子串匹配')
490
578
  })
491
579
 
492
580
  it('非法正则 pattern → 字面子串兜底不抛错(零命中,S-6)', async () => {
493
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, dir)
581
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, { agentDir: dir })
494
582
  const d = r.details as { hits: unknown[] }
495
583
  expect(d.hits).toHaveLength(0)
496
584
  })
@@ -498,7 +586,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
498
586
  it('scope 过滤:scope=assistant 零命中(fixture 仅 user 角色,S-6)', async () => {
499
587
  const r = await handleSessionRead(
500
588
  { action: 'search', session: SID, pattern: 'aaa', scope: 'assistant' },
501
- dir,
589
+ { agentDir: dir },
502
590
  )
503
591
  const d = r.details as { hits: unknown[] }
504
592
  expect(d.hits).toHaveLength(0)
@@ -525,7 +613,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
525
613
  )
526
614
  const r = await handleSessionRead(
527
615
  { action: 'search', session: sid2, pattern: 'aa', limit: 2 },
528
- dir,
616
+ { agentDir: dir },
529
617
  )
530
618
  const d = r.details as { hits: unknown[]; truncated: boolean }
531
619
  expect(d.truncated).toBe(true)
@@ -536,7 +624,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
536
624
  const ac = new AbortController()
537
625
  ac.abort()
538
626
  await expect(
539
- handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, dir, ac.signal),
627
+ handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, { agentDir: dir }, ac.signal),
540
628
  ).rejects.toThrow(/中断/)
541
629
  })
542
630
  })
@@ -571,7 +659,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
571
659
  message: {
572
660
  role: 'assistant',
573
661
  content: [
574
- { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '/tmp/log.txt' } },
662
+ { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: join(tmpdir(), 'log.txt') } },
575
663
  ],
576
664
  },
577
665
  }),
@@ -595,7 +683,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
595
683
  message: {
596
684
  role: 'assistant',
597
685
  content: [
598
- { type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: '/tmp/out.txt' } },
686
+ { type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: join(tmpdir(), 'out.txt') } },
599
687
  ],
600
688
  },
601
689
  }),
@@ -611,7 +699,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
611
699
  },
612
700
  }),
613
701
  ])
614
- const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
702
+ const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
615
703
  const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
616
704
  const hashes = d.items.map((it) => it.hash)
617
705
  expect(hashes).toContain('abc1234f')
@@ -652,7 +740,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
652
740
  message: {
653
741
  role: 'assistant',
654
742
  content: [
655
- { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '/tmp/deploy.txt' } },
743
+ { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: join(tmpdir(), 'deploy.txt') } },
656
744
  ],
657
745
  },
658
746
  }),
@@ -669,7 +757,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
669
757
  },
670
758
  }),
671
759
  ])
672
- const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
760
+ const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
673
761
  const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
674
762
  const dupes = d.items.filter((it) => it.hash === 'def5678a')
675
763
  expect(dupes).toHaveLength(1)
@@ -811,7 +899,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
811
899
  // export 的 details.path = session-view-<sessionId>.md,含 header 真实 id,不含文件名
812
900
  const r = await handleSessionRead(
813
901
  { action: 'export', session: filePath, format: 'outline' },
814
- dir,
902
+ { agentDir: dir },
815
903
  )
816
904
  const d = r.details as { path: string }
817
905
  expect(d.path).toContain(fileId)
@@ -826,10 +914,10 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
826
914
  `not-exist-${Date.now()}.jsonl`,
827
915
  )
828
916
  await expect(
829
- handleSessionRead({ action: 'outline', session: filePath }, dir),
917
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
830
918
  ).rejects.toThrow(/读取失败.*文件不存在/)
831
919
  await expect(
832
- handleSessionRead({ action: 'outline', session: filePath }, dir),
920
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
833
921
  ).rejects.toThrow('👉')
834
922
  })
835
923
 
@@ -838,7 +926,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
838
926
  await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
839
927
  await writeFile(filePath, 'not jsonl')
840
928
  await expect(
841
- handleSessionRead({ action: 'outline', session: filePath }, dir),
929
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
842
930
  ).rejects.toThrow(/读取失败.*非 \.jsonl/)
843
931
  })
844
932
 
@@ -847,7 +935,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
847
935
  await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
848
936
  await writeFile(filePath, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
849
937
  await expect(
850
- handleSessionRead({ action: 'outline', session: filePath }, dir),
938
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
851
939
  ).rejects.toThrow(/读取失败.*首行非合法 session header/)
852
940
  })
853
941
 
@@ -856,29 +944,38 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
856
944
  await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
857
945
  await writeFile(filePath, '')
858
946
  await expect(
859
- handleSessionRead({ action: 'outline', session: filePath }, dir),
947
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
860
948
  ).rejects.toThrow(/读取失败.*首行非合法 session header/)
861
949
  })
862
950
 
863
951
  it('TC6: ~ 前缀展开到 homedir(文件实际在 homedir 下)', async () => {
864
- const home = homedir()
865
- const tmpUnderHome = await mkdtemp(join(home, '.sr-w2-test-'))
952
+ // fs-guard:真实 homedir 根在白名单外(禁止在用户主目录根散落 .sr-w2-test-*)——
953
+ // homedir 经文件头 vi.mock 指到 mkdtemp tmp(白名单内),「~ 前缀展开」断言语义不变
954
+ const fakeHome = await mkdtemp(join(tmpdir(), 'tool-handler-tc6-home-'))
955
+ homeState.fakeHome = fakeHome
866
956
  try {
867
- const fileId = '019e6c96-dddd-eeee-ffff-0000000006c1'
868
- const sessionFile = join(tmpUnderHome, 's.jsonl')
869
- await writeFile(
870
- sessionFile,
871
- JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }) + '\n',
872
- )
873
- // ~/开头的相对 homedir 路径
874
- const tildePath = '~/' + sessionFile.slice(home.length + 1)
875
- const r = await handleSessionRead(
876
- { action: 'export', session: tildePath, format: 'outline' },
877
- dir,
878
- )
879
- expect((r.details as { path: string }).path).toContain(fileId)
957
+ const home = homedir()
958
+ const tmpUnderHome = await mkdtemp(join(home, '.sr-w2-test-'))
959
+ try {
960
+ const fileId = '019e6c96-dddd-eeee-ffff-0000000006c1'
961
+ const sessionFile = join(tmpUnderHome, 's.jsonl')
962
+ await writeFile(
963
+ sessionFile,
964
+ JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }) + '\n',
965
+ )
966
+ // ~/开头的相对 homedir 路径
967
+ const tildePath = '~/' + sessionFile.slice(home.length + 1)
968
+ const r = await handleSessionRead(
969
+ { action: 'export', session: tildePath, format: 'outline' },
970
+ { agentDir: dir },
971
+ )
972
+ expect((r.details as { path: string }).path).toContain(fileId)
973
+ } finally {
974
+ await rm(tmpUnderHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
975
+ }
880
976
  } finally {
881
- await rm(tmpUnderHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
977
+ homeState.fakeHome = undefined
978
+ await rm(fakeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
882
979
  }
883
980
  })
884
981
  })
@@ -898,7 +995,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
898
995
  await makeFixtureSubagent(dir, 'sa-aaa', { realSessionId: realId, firstUserText: 'do task' })
899
996
  const r = await handleSessionRead(
900
997
  { action: 'export', session: 'sa-aaa', format: 'outline' },
901
- dir,
998
+ { agentDir: dir },
902
999
  )
903
1000
  const d = r.details as { path: string }
904
1001
  expect(d.path).toContain(realId)
@@ -913,11 +1010,11 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
913
1010
  sessionFileExists: false,
914
1011
  })
915
1012
  await expect(
916
- handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir),
1013
+ handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir }),
917
1014
  ).rejects.toThrow('session 文件不存在')
918
1015
  // 错误含 manifest 全部元数据 + 👉
919
1016
  try {
920
- await handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir)
1017
+ await handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir })
921
1018
  } catch (e) {
922
1019
  const msg = (e as Error).message
923
1020
  expect(msg).toContain('sa-gc')
@@ -931,10 +1028,10 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
931
1028
 
932
1029
  it('TC9: sa-id 0 命中(可能 running)→ ES2(含 family 指引 + 完整 id 提示 + 👉)', async () => {
933
1030
  await expect(
934
- handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir),
1031
+ handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
935
1032
  ).rejects.toThrow('无匹配 record')
936
1033
  try {
937
- await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
1034
+ await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
938
1035
  } catch (e) {
939
1036
  const msg = (e as Error).message
940
1037
  expect(msg).toContain('可能尚未落盘')
@@ -949,7 +1046,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
949
1046
  })
950
1047
  // 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中 → ES2
951
1048
  await expect(
952
- handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, dir),
1049
+ handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
953
1050
  ).rejects.toThrow('无匹配 record')
954
1051
  })
955
1052
 
@@ -978,12 +1075,12 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
978
1075
  )
979
1076
  }
980
1077
  await expect(
981
- handleSessionRead({ action: 'outline', session: 'sa-dup' }, dir),
1078
+ handleSessionRead({ action: 'outline', session: 'sa-dup' }, { agentDir: dir }),
982
1079
  ).rejects.toThrow(/匹配 2 个 record.*数据异常/)
983
1080
  })
984
1081
 
985
1082
  it('CQ3: sa-id 命中但 sessionFile header 读不出 → F6 风格(不降级 record.id 当 sessionId)', async () => {
986
- // sessionFile 存在但首行非 session header → readSessionHeaderId 返 undefined
1083
+ // sessionFile 存在但首行非 session header → readSessionHeaderIdSync 返 undefined
987
1084
  const slug = '--demo-cwd--'
988
1085
  const sessionFile = join(dir, 'subagents', slug, 'sessions', 'bad.jsonl')
989
1086
  await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
@@ -1001,7 +1098,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
1001
1098
  )
1002
1099
  // 抛 F6 风格(读取失败 + 首行非合法 session header),不降级返回 sa-bad 当 sessionId
1003
1100
  await expect(
1004
- handleSessionRead({ action: 'outline', session: 'sa-bad' }, dir),
1101
+ handleSessionRead({ action: 'outline', session: 'sa-bad' }, { agentDir: dir }),
1005
1102
  ).rejects.toThrow(/读取失败.*首行非合法 session header/)
1006
1103
  })
1007
1104
  })
@@ -1026,7 +1123,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1026
1123
  // 无 source → 两者
1027
1124
  const rBoth = await handleSessionRead(
1028
1125
  { action: 'find', query: sharedFragment },
1029
- dir,
1126
+ { agentDir: dir },
1030
1127
  )
1031
1128
  const dBoth = rBoth.details as {
1032
1129
  matches: Array<{ source: string; sessionId: string }>
@@ -1037,7 +1134,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1037
1134
  // source:subagent → 只 sub
1038
1135
  const rSub = await handleSessionRead(
1039
1136
  { action: 'find', query: sharedFragment, source: 'subagent' },
1040
- dir,
1137
+ { agentDir: dir },
1041
1138
  )
1042
1139
  const dSub = rSub.details as {
1043
1140
  matches: Array<{ source: string; sessionId: string }>
@@ -1049,7 +1146,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1049
1146
  // source:main → 只 main
1050
1147
  const rMain = await handleSessionRead(
1051
1148
  { action: 'find', query: sharedFragment, source: 'main' },
1052
- dir,
1149
+ { agentDir: dir },
1053
1150
  )
1054
1151
  const dMain = rMain.details as {
1055
1152
  matches: Array<{ source: string; sessionId: string }>
@@ -1069,14 +1166,14 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1069
1166
  // 无 source → main+sub 共享片段 → 2 匹配 → F2 消歧
1070
1167
  const rMulti = await handleSessionRead(
1071
1168
  { action: 'outline', session: sharedFragment },
1072
- dir,
1169
+ { agentDir: dir },
1073
1170
  )
1074
1171
  expect((rMulti.details as { ambiguous: boolean }).ambiguous).toBe(true)
1075
1172
 
1076
1173
  // source:main → 收窄到 main → 唯一匹配 → outline 成功,且是 main(export path 含 mainId)
1077
1174
  const rExp = await handleSessionRead(
1078
1175
  { action: 'export', session: sharedFragment, source: 'main', format: 'outline' },
1079
- dir,
1176
+ { agentDir: dir },
1080
1177
  )
1081
1178
  expect((rExp.details as { path: string }).path).toContain(mainId)
1082
1179
  })
@@ -1087,7 +1184,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1087
1184
  const manifests = await listRecordManifests(REAL)
1088
1185
  const alive = manifests.filter((m) => existsSync(m.sessionFile))
1089
1186
  if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
1090
- const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, REAL)
1187
+ const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, { agentDir: REAL })
1091
1188
  const d = r.details as { turns: unknown[] }
1092
1189
  expect(d.turns.length).toBeGreaterThan(0)
1093
1190
  })
@@ -1098,7 +1195,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1098
1195
  (m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
1099
1196
  )
1100
1197
  if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
1101
- const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, REAL)
1198
+ const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, { agentDir: REAL })
1102
1199
  expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1103
1200
  })
1104
1201
 
@@ -1109,7 +1206,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1109
1206
  // 绝对路径形态直接读(M0 入口不依赖 findSessions)
1110
1207
  const r = await handleSessionRead(
1111
1208
  { action: 'outline', session: alive[0].sessionFile },
1112
- REAL,
1209
+ { agentDir: REAL },
1113
1210
  )
1114
1211
  expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1115
1212
  })
@@ -1119,14 +1216,14 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1119
1216
  const gc = manifests.filter((m) => !existsSync(m.sessionFile))
1120
1217
  if (gc.length === 0) return // 本机无 GC 数据则跳过
1121
1218
  await expect(
1122
- handleSessionRead({ action: 'outline', session: gc[0].id }, REAL),
1219
+ handleSessionRead({ action: 'outline', session: gc[0].id }, { agentDir: REAL }),
1123
1220
  ).rejects.toThrow('session 文件不存在')
1124
1221
  })
1125
1222
 
1126
1223
  it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
1127
1224
  const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
1128
1225
  await expect(
1129
- handleSessionRead({ action: 'outline', session: fakeId }, REAL),
1226
+ handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
1130
1227
  ).rejects.toThrow('无匹配 record')
1131
1228
  })
1132
1229
  }, 60000)
@@ -1258,7 +1355,7 @@ describe('doWorkflow(w6,fixture)', () => {
1258
1355
  ])
1259
1356
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-single-1', path: wfPath })
1260
1357
 
1261
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1358
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1262
1359
  const d = r.details as {
1263
1360
  runs: Array<{
1264
1361
  runId: string
@@ -1296,7 +1393,7 @@ describe('doWorkflow(w6,fixture)', () => {
1296
1393
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-1', path: wf1 })
1297
1394
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-2', path: wf2 })
1298
1395
 
1299
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1396
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1300
1397
  const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
1301
1398
  expect(d.runs).toHaveLength(2)
1302
1399
  expect(d.runIds).toHaveLength(2)
@@ -1321,7 +1418,7 @@ describe('doWorkflow(w6,fixture)', () => {
1321
1418
 
1322
1419
  const r = await handleSessionRead(
1323
1420
  { action: 'workflow', session: WF_ROOT, runId: 'wf-filter-1' },
1324
- dir,
1421
+ { agentDir: dir },
1325
1422
  )
1326
1423
  const d = r.details as {
1327
1424
  runs: Array<{ runId: string }>
@@ -1350,7 +1447,7 @@ describe('doWorkflow(w6,fixture)', () => {
1350
1447
 
1351
1448
  const r = await handleSessionRead(
1352
1449
  { action: 'workflow', session: WF_ROOT, runId: 'wf-nonexist' },
1353
- dir,
1450
+ { agentDir: dir },
1354
1451
  )
1355
1452
  const d = r.details as { runs: unknown[]; runIds: string[]; requestedRunId?: string }
1356
1453
  expect(d.runs).toEqual([])
@@ -1369,7 +1466,7 @@ describe('doWorkflow(w6,fixture)', () => {
1369
1466
  await wfMainSession(dir, slug, WF_ROOT)
1370
1467
  // 无 wf-link(session 存在但未发起 workflow)
1371
1468
 
1372
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1469
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1373
1470
  const d = r.details as { runs: unknown[]; runIds: unknown[]; sessionId?: string }
1374
1471
  expect(d.runs).toEqual([])
1375
1472
  expect(d.runIds).toEqual([])
@@ -1391,7 +1488,7 @@ describe('doWorkflow(w6,fixture)', () => {
1391
1488
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-1', path: ghostPath })
1392
1489
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-2', path: wf2 })
1393
1490
 
1394
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1491
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1395
1492
  const d = r.details as {
1396
1493
  runs: Array<{ runId: string }>
1397
1494
  runIds: string[]
@@ -1421,7 +1518,7 @@ describe('doWorkflow(w6,fixture)', () => {
1421
1518
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-jump-1', path: wfPath })
1422
1519
 
1423
1520
  // 第一次:workflow 概览,拿 call sessionId
1424
- const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1521
+ const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1425
1522
  const dWf = rWf.details as {
1426
1523
  runs: Array<{ steps: Array<{ sessionId: string; sessionFile: string }> }>
1427
1524
  }
@@ -1432,7 +1529,7 @@ describe('doWorkflow(w6,fixture)', () => {
1432
1529
  // 第二次:用 call sessionId 调 outline(m0 resolveSessionId 三形态复用)
1433
1530
  const rOutline = await handleSessionRead(
1434
1531
  { action: 'outline', session: callSessionId },
1435
- dir,
1532
+ { agentDir: dir },
1436
1533
  )
1437
1534
  const dOutline = rOutline.details as { turns: unknown[] }
1438
1535
  expect(dOutline.turns.length).toBeGreaterThan(0)
@@ -1469,7 +1566,7 @@ describe('doWorkflow(w6,fixture)', () => {
1469
1566
  { flag: 'a' },
1470
1567
  )
1471
1568
 
1472
- const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, dir)
1569
+ const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, { agentDir: dir })
1473
1570
  const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
1474
1571
  expect(d.runs).toHaveLength(1)
1475
1572
  expect(d.runs[0].runId).toBe('wf-sub-1')
@@ -1490,7 +1587,7 @@ describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
1490
1587
  it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
1491
1588
  const r = await handleSessionRead(
1492
1589
  { action: 'workflow', session: REAL_WF_SESSION },
1493
- REAL,
1590
+ { agentDir: REAL },
1494
1591
  )
1495
1592
  const d = r.details as {
1496
1593
  runs: Array<{ runId: string; status: string; steps: unknown[] }>
@@ -1529,7 +1626,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1529
1626
  })
1530
1627
 
1531
1628
  it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
1532
- const r = await handleSessionRead({ action: 'family', session: MAIN }, dir)
1629
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1533
1630
  // details 是 Family 对象(root/subagents/workflows),非 { tree }
1534
1631
  const d = r.details as {
1535
1632
  root: { sessionId: string }
@@ -1547,7 +1644,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1547
1644
  it('TC-m3b-dofamily-recursive-true:recursive=true → ExecutionTree(details.tree)', async () => {
1548
1645
  const r = await handleSessionRead(
1549
1646
  { action: 'family', session: MAIN, recursive: true },
1550
- dir,
1647
+ { agentDir: dir },
1551
1648
  )
1552
1649
  const d = r.details as {
1553
1650
  tree: {
@@ -1573,6 +1670,142 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1573
1670
  })
1574
1671
  })
1575
1672
 
1673
+ // ===========================================================================
1674
+ // ext-simplify-04 U4/E2(D2②):formatFamilyText subagents 行富字段展示
1675
+ // status 终态短标签 + agent 名 + task 截 60 摘要;孤儿只标 [已清理];P4 输出量级守卫。
1676
+ // ===========================================================================
1677
+
1678
+ describe('formatFamilyText subagents 行富字段(ext-simplify-04 U4 / E2)', () => {
1679
+ const MAIN = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f1'
1680
+ const SUB = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f2'
1681
+ let dir: string
1682
+
1683
+ beforeEach(async () => {
1684
+ dir = await mkdtemp(join(tmpdir(), 'tool-handler-family-rich-'))
1685
+ await makeFixtureSession(dir, MAIN, 'main session')
1686
+ })
1687
+ afterEach(async () => {
1688
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1689
+ })
1690
+
1691
+ /** alive subagent:session 文件(header + 可选 identity 尾行)+ 可选 manifest 富字段 */
1692
+ async function writeSubagent(
1693
+ realId: string,
1694
+ opts: {
1695
+ manifest?: { agentName?: string; task?: string; slug?: string; model?: string; status?: string }
1696
+ identityTask?: string
1697
+ identityAgent?: string
1698
+ } = {},
1699
+ ): Promise<void> {
1700
+ const sessionDir = join(dir, 'subagents', '--demo-cwd--', 'sessions')
1701
+ await mkdir(sessionDir, { recursive: true })
1702
+ const subPath = join(sessionDir, `${realId}.jsonl`)
1703
+ const lines = [JSON.stringify({ type: 'session', id: realId, cwd: '/demo' })]
1704
+ if (opts.identityTask !== undefined || opts.identityAgent !== undefined) {
1705
+ lines.push(
1706
+ JSON.stringify({
1707
+ type: 'custom',
1708
+ customType: 'subagent-identity',
1709
+ data: {
1710
+ id: `sa-${realId.slice(0, 8)}`,
1711
+ rootSessionId: MAIN,
1712
+ slug: 'identity-slug',
1713
+ agent: opts.identityAgent ?? 'explorer',
1714
+ task: opts.identityTask ?? 't',
1715
+ },
1716
+ }),
1717
+ )
1718
+ }
1719
+ await writeFile(subPath, lines.join('\n') + '\n')
1720
+ if (opts.manifest) {
1721
+ const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
1722
+ await mkdir(recordsDir, { recursive: true })
1723
+ await writeFile(
1724
+ join(recordsDir, `sa-${realId}.json`),
1725
+ JSON.stringify({
1726
+ id: `sa-${realId}`,
1727
+ rootSessionId: MAIN,
1728
+ sessionFile: subPath,
1729
+ ...opts.manifest,
1730
+ }),
1731
+ )
1732
+ }
1733
+ }
1734
+
1735
+ /** 孤儿 manifest:sessionFile 指向不存在路径(模拟 .jsonl 被 GC) */
1736
+ async function writeOrphanManifest(
1737
+ saId: string,
1738
+ fields: { agentName?: string; task?: string; slug?: string; status?: string } = {},
1739
+ ): Promise<void> {
1740
+ const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
1741
+ await mkdir(recordsDir, { recursive: true })
1742
+ await writeFile(
1743
+ join(recordsDir, `${saId}.json`),
1744
+ JSON.stringify({ id: saId, rootSessionId: MAIN, sessionFile: '/nonexistent/gc.jsonl', ...fields }),
1745
+ )
1746
+ }
1747
+
1748
+ it('manifest 主路径:subagents 行含 [status] agent 名 · task 摘要;root 段不变', async () => {
1749
+ await writeSubagent(SUB, {
1750
+ manifest: { agentName: 'coder', task: '修复登录表单校验并补测试', slug: 'fix-login', status: 'completed' },
1751
+ })
1752
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1753
+ const text = r.content[0].text
1754
+ // 富字段行形态(设计 §3.1):slug 后接 [status] agent · task 摘要
1755
+ expect(text).toContain('slug=fix-login [completed] coder · 修复登录表单校验并补测试')
1756
+ // root/parents/forks/workflows 段不变(root 行含完整 id + 日期括号)
1757
+ expect(text).toContain(`root: ${MAIN} (`)
1758
+ })
1759
+
1760
+ it('P4 输出量级:task 截 60 字符(超出加省略号),换行压平不破坏行结构', async () => {
1761
+ const SUB2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f3'
1762
+ await writeSubagent(SUB, {
1763
+ manifest: { agentName: 'coder', task: 'A'.repeat(100), slug: 'long-task', status: 'running' },
1764
+ })
1765
+ await writeSubagent(SUB2, {
1766
+ manifest: { agentName: 'worker', task: 'line1\nline2\ttabbed', slug: 'nl-task', status: 'failed' },
1767
+ })
1768
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1769
+ const text = r.content[0].text
1770
+ // 截断:恰 60 字符 + 省略号,原文 61+ 连续段不出现
1771
+ expect(text).toContain(`${'A'.repeat(60)}…`)
1772
+ expect(text).not.toContain('A'.repeat(61))
1773
+ // 换行/制表压平为单空格
1774
+ expect(text).toContain('slug=nl-task [failed] worker · line1 line2 tabbed')
1775
+ // 行结构:subagents 段 = 头行 + 每 subagent 恰一行(换行未折叠出额外行)
1776
+ const lines = text.split('\n')
1777
+ const headIdx = lines.indexOf('subagents:')
1778
+ const subLines = lines.slice(headIdx + 1)
1779
+ expect(subLines).toHaveLength(2)
1780
+ expect(subLines.every((l) => l.startsWith(' ') && l.trim().length > 0)).toBe(true)
1781
+ })
1782
+
1783
+ it('孤儿(cleanedUp):仍标 [已清理],不展开 status/agent/task 摘要', async () => {
1784
+ await writeOrphanManifest('sa-ghost-id', {
1785
+ agentName: 'worker',
1786
+ task: 'ghost task',
1787
+ slug: 'ghost-slug',
1788
+ status: 'completed',
1789
+ })
1790
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1791
+ const text = r.content[0].text
1792
+ expect(text).toContain('slug=ghost-slug [已清理]')
1793
+ // 已清理即终局:manifest 富字段不进文本(无深读入口,摘要无消费价值)
1794
+ expect(text).not.toContain('ghost task')
1795
+ expect(text).not.toContain('[completed]')
1796
+ })
1797
+
1798
+ it('P-fallback(无 manifest):identity 回退 task/agent 仍展示,status 无则无标签', async () => {
1799
+ await writeSubagent(SUB, { identityTask: 'fix bug', identityAgent: 'worker' })
1800
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1801
+ const text = r.content[0].text
1802
+ // P-fallback:task/agent 从 identity 回退(slug 取 identity.slug),status 不可回退 → 无 [x] 标签
1803
+ expect(text).toContain('slug=identity-slug worker · fix bug')
1804
+ expect(text).not.toContain('[completed]')
1805
+ expect(text).not.toContain('[running]')
1806
+ })
1807
+ })
1808
+
1576
1809
  // ===========================================================================
1577
1810
  // doctor action(u8:design 2026-09-10 §6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)
1578
1811
  // 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
@@ -1581,7 +1814,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1581
1814
  // doctor details 契约直用 doctor.ts 导出的 DoctorDetails(不再自建局部镜像——生产侧
1582
1815
  // 形状漂移由编译期捕捉,S-R2 同步)
1583
1816
 
1584
- describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob + 缓存)', () => {
1817
+ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob)', () => {
1585
1818
  let tmp: string
1586
1819
 
1587
1820
  const SLUG = '--Users-foo--'
@@ -1593,8 +1826,8 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1593
1826
  }
1594
1827
 
1595
1828
  /**
1596
- * xyz-agent 形态四根 fixture:dataDir 目录名带 .xyz-agent 前缀(detectEnvironment 的
1597
- * PI_CODING_AGENT_DIR 形态判据 `<*>/.xyz-agent*\/agent` 可命中,托管判定走正态)。
1829
+ * taiji 形态四根 fixture:dataDir 目录名带 .taiji 前缀(detectEnvironment 的
1830
+ * PI_CODING_AGENT_DIR 形态判据 `<*>/.taiji*\/agent` 可命中,托管判定走正态)。
1598
1831
  * live=agent/sessions 与 default 同字面路径(default 去重)、legacy 独立非空、subagent 独立。
1599
1832
  */
1600
1833
  async function buildFourRootFixture(): Promise<{
@@ -1602,7 +1835,7 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1602
1835
  agentDir: string
1603
1836
  signals: SessionReadSignals
1604
1837
  }> {
1605
- const dataDir = join(tmp, '.xyz-agent-fixture')
1838
+ const dataDir = join(tmp, '.taiji-fixture')
1606
1839
  const agentDir = join(dataDir, 'agent')
1607
1840
  await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'), 'aaaaaaaa')
1608
1841
  await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
@@ -1615,9 +1848,9 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1615
1848
  agentDir,
1616
1849
  liveSessionDir: join(agentDir, 'sessions'),
1617
1850
  env: {
1618
- XYZ_AGENT_EXT_LOG: '1',
1851
+ TAIJI_AGENT_EXT_LOG: '1',
1619
1852
  PI_CODING_AGENT_DIR: agentDir,
1620
- XYZ_AGENT_DATA_DIR: dataDir,
1853
+ TAIJI_AGENT_DATA_DIR: dataDir,
1621
1854
  },
1622
1855
  },
1623
1856
  }
@@ -1635,8 +1868,8 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1635
1868
  const r = await handleSessionRead({ action: 'doctor' }, signals)
1636
1869
  const text = r.content[0].text
1637
1870
 
1638
- // 环境判定行:双信号合取 → xyz-agent(托管)+ 数据目录 + evidence 正态
1639
- expect(text).toContain('环境判定:xyz-agent(托管)')
1871
+ // 环境判定行:双信号合取 → taiji(托管)+ 数据目录 + evidence 正态
1872
+ expect(text).toContain('环境判定:taiji(托管)')
1640
1873
  expect(text).toContain(`数据目录:${dataDir}`)
1641
1874
  expect(text).toContain('依据:')
1642
1875
  expect(text).toContain(`PI_CODING_AGENT_DIR='${agentDir}' → 形态匹配`)
@@ -1679,7 +1912,7 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1679
1912
  const { signals } = await buildFourRootFixture()
1680
1913
  const positive = await handleSessionRead({ action: 'doctor' }, signals)
1681
1914
  expect(positive.content[0].text).toContain(
1682
- `告警:[legacy] 根 ${join(tmp, '.xyz-agent-fixture', 'sessions')} 非空(1 文件)`,
1915
+ `告警:[legacy] 根 ${join(tmp, '.taiji-fixture', 'sessions')} 非空(1 文件)`,
1683
1916
  )
1684
1917
 
1685
1918
  // 反态:legacy 目录不存在
@@ -1700,43 +1933,6 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1700
1933
  expect(deduped.content[0].text).toContain('已去重')
1701
1934
  })
1702
1935
 
1703
- it('独立 glob 残留探测:pi/ 含 agent|sessions 形态列出 + 迁移指引;pi.backup-v2-* 备份列出;基点 = dirname(agentDir)', async () => {
1704
- const dataDir = join(tmp, 'data')
1705
- const agentDir = join(dataDir, 'agent')
1706
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
1707
- // 未迁移旧布局 pi/(含 agent/ 形态)
1708
- await mkdir(join(dataDir, 'pi', 'agent'), { recursive: true })
1709
- await writeJsonl(join(dataDir, 'pi', 'agent', 'sessions', 'old.jsonl'), 'old-id')
1710
- // 迁移备份(含 sessions/ 形态——备份即原 pi/ 改名)
1711
- const backup = join(dataDir, 'pi.backup-v2-1725900000000')
1712
- await mkdir(join(backup, 'sessions'), { recursive: true })
1713
- await writeJsonl(join(backup, 'sessions', 'bak.jsonl'), 'bak-id')
1714
-
1715
- const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
1716
- const text = r.content[0].text
1717
- expect(text).toContain(`旧布局残留探测(基点 ${dataDir})`)
1718
- expect(text).toContain(` - ${join(dataDir, 'pi')}(未迁移旧布局 pi/)`)
1719
- expect(text).toContain(` - ${backup}(迁移备份 pi.backup-v2-*)`)
1720
- // 同一迁移指引(§6.11:doctor 与 u14b 启动探测同指引)
1721
- expect(text).toContain('👉 关闭应用后运行 scripts/migrate-pi-layout-v2.mjs 完成迁移。')
1722
-
1723
- const d = r.details as DoctorDetails
1724
- expect(d.globBase).toBe(dataDir)
1725
- expect(d.leftovers.map((l) => l.kind).sort()).toEqual(['backup', 'unmigrated'])
1726
- })
1727
-
1728
- it('残留探测反态:pi/ 空壳(无 agent|sessions 子目录)不报——防纯 pi 宿主 ~/.pi/pi/ 误报(§6.11 v9.1)', async () => {
1729
- const dataDir = join(tmp, 'data')
1730
- const agentDir = join(dataDir, 'agent')
1731
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
1732
- // 纯 pi 宿主下任意来源的 pi/ 目录:只有无关子目录,无 agent/ 与 sessions/ 形态
1733
- await mkdir(join(dataDir, 'pi', 'extensions'), { recursive: true })
1734
-
1735
- const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
1736
- expect(r.content[0].text).not.toContain('旧布局残留')
1737
- expect((r.details as DoctorDetails).leftovers).toEqual([])
1738
- })
1739
-
1740
1936
  it('subagent 根默认不扫(只列路径与可扫性);includeSubagents:true 才扫出文件数(§6.3)', async () => {
1741
1937
  const { agentDir, signals } = await buildFourRootFixture()
1742
1938
 
@@ -1753,59 +1949,31 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1753
1949
  expect(fullSub?.exists).toBe(true)
1754
1950
  })
1755
1951
 
1756
- it('进程内缓存:同根二次调用命中缓存不重扫;根目录 mtime 变化即失效重扫(§7B 要点 8)', async () => {
1952
+ it('无缓存形态(ext-simplify-04 U3):重复调用输出一致(每次实扫),新落盘文件立即可见', async () => {
1953
+ // 原三个用例(缓存命中不重扫 / TTL 到期失效 / find 不读 doctor 缓存 PS-14)随
1954
+ // doctor 缓存机删除而合并为本用例——无缓存层后「find/doctor 读到旧计数」的形态
1955
+ // 在结构上不存在,守卫点收敛为:输出无「缓存命中」行 + 每次调用实扫(新增文件
1956
+ // 下一次调用即见,反而更实时)
1757
1957
  const agentDir = join(tmp, 'agent')
1758
1958
  await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
1759
1959
  const signals: SessionReadSignals = { agentDir }
1760
1960
 
1761
1961
  const first = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
1762
1962
  const firstDef = first.roots.find((x) => x.kind === 'default')!
1763
- expect(firstDef.cached).toBeUndefined() // 首扫
1764
1963
  expect(firstDef.fileCount).toBe(1)
1765
1964
 
1766
- const second = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
1767
- const secondDef = second.roots.find((x) => x.kind === 'default')!
1768
- expect(secondDef.cached).toBe(true) // 命中缓存,未重扫
1769
- expect(secondDef.fileCount).toBe(1)
1965
+ const second = await handleSessionRead({ action: 'doctor' }, signals)
1966
+ const secondDef = (second.details as DoctorDetails).roots.find((x) => x.kind === 'default')!
1967
+ expect(secondDef.fileCount).toBe(1) // 两次输出一致(同目录无变化)
1968
+ expect(second.content[0].text).not.toContain('缓存命中')
1770
1969
 
1771
- // 根目录 mtime 变化(根层新增文件)→ 失效重扫,计数更新
1772
- await writeJsonl(join(agentDir, 'sessions', 'b.jsonl'), 'bbbbbbbb')
1970
+ // 新 session 落盘 → 下一次 doctor / find 均立即可见(无缓存层,每次实扫)
1971
+ await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
1773
1972
  const third = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
1774
- const thirdDef = third.roots.find((x) => x.kind === 'default')!
1775
- expect(thirdDef.cached).toBeUndefined()
1776
- expect(thirdDef.fileCount).toBe(2)
1777
- })
1778
-
1779
- it('缓存 TTL 到期失效重扫(秒级 TTL,fake timers 推进时钟)', async () => {
1780
- vi.useFakeTimers()
1781
- try {
1782
- const agentDir = join(tmp, 'agent-ttl')
1783
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
1784
- const signals: SessionReadSignals = { agentDir }
1785
- const first = (await handleSessionRead({ action: 'doctor' }, signals))
1786
- .details as DoctorDetails
1787
- expect(first.roots.find((x) => x.kind === 'default')!.cached).toBeUndefined()
1788
- await vi.advanceTimersByTimeAsync(DOCTOR_CACHE_TTL_MS + 1)
1789
- const second = (await handleSessionRead({ action: 'doctor' }, signals))
1790
- .details as DoctorDetails
1791
- // TTL 过期 → 即使 mtime 未变也重扫
1792
- expect(second.roots.find((x) => x.kind === 'default')!.cached).toBeUndefined()
1793
- } finally {
1794
- vi.useRealTimers()
1795
- }
1796
- })
1797
-
1798
- it('find 不读 doctor 缓存(§7B 要点 8 PS-14):doctor 缓存 0 文件后新建 session,find 立即可见', async () => {
1799
- const agentDir = join(tmp, 'agent')
1800
- // doctor 首跑时 main 根为空 → 缓存 fileCount=0(PS-14 形态:首条 assistant 前 jsonl 不落盘)
1801
- await mkdir(join(agentDir, 'sessions'), { recursive: true })
1802
- await handleSessionRead({ action: 'doctor' }, { agentDir })
1803
- // 之后新 session 落盘
1804
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'found-later.jsonl'), 'id-find-later-xyz')
1805
- // find 实扫立即可见——若 find 读 doctor 缓存(0 候选)此断言必红
1806
- const r = await handleSessionRead({ action: 'find', query: 'id-find-later-xyz' }, agentDir)
1973
+ expect(third.roots.find((x) => x.kind === 'default')!.fileCount).toBe(2)
1974
+ const r = await handleSessionRead({ action: 'find', query: 'bbbbbbbb' }, { agentDir })
1807
1975
  const d = r.details as { matches: Array<{ sessionId: string }> }
1808
- expect(d.matches.some((m) => m.sessionId.includes('id-find-later-xyz'))).toBe(true)
1976
+ expect(d.matches.some((m) => m.sessionId.includes('bbbbbbbb'))).toBe(true)
1809
1977
  })
1810
1978
 
1811
1979
  it('env/bundleUrl 信号缺失降级:仍出环境判定行(standalone-pi · 未知)+ 完整根表,不抛错', async () => {
@@ -1815,7 +1983,7 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1815
1983
  const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
1816
1984
  const text = r.content[0].text
1817
1985
  expect(text).toContain('环境判定:standalone-pi · 发行形态:未知(不猜)')
1818
- expect(text).toContain('XYZ_AGENT_EXT_LOG=<未设置> → 未命中')
1986
+ expect(text).toContain('TAIJI_AGENT_EXT_LOG=<未设置> → 未命中')
1819
1987
  expect(text).toContain('会话根(按优先级)')
1820
1988
  expect(text).toContain('[default]')
1821
1989
  expect(text).toContain(
@@ -1823,13 +1991,12 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1823
1991
  )
1824
1992
  })
1825
1993
 
1826
- it('空 agentDir 防御:无根表、诊断「无候选根」,残留探测不触发(不扫 cwd 相对路径)', async () => {
1994
+ it('空 agentDir 防御:无根表、诊断「无候选根」(不扫 cwd 相对路径)', async () => {
1827
1995
  const r = await handleSessionRead({ action: 'doctor' }, { agentDir: '' })
1828
1996
  const text = r.content[0].text
1829
1997
  expect(text).toContain('诊断:无候选根')
1830
1998
  expect(text).not.toContain('[default]')
1831
1999
  expect((r.details as DoctorDetails).roots).toEqual([])
1832
- expect((r.details as DoctorDetails).leftovers).toEqual([])
1833
2000
  })
1834
2001
  })
1835
2002
 
@@ -1871,7 +2038,7 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
1871
2038
 
1872
2039
  /** find action 便捷调用(agentDir = tmp/agent)。 */
1873
2040
  function find(query: string, extra?: Partial<SessionReadParams>): Promise<ToolResultLike> {
1874
- return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
2041
+ return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
1875
2042
  }
1876
2043
 
1877
2044
  interface ToolResultLike {
@@ -1985,9 +2152,10 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
1985
2152
  expect((hexJunk.details as { matches: unknown[] }).matches).toHaveLength(0)
1986
2153
  })
1987
2154
 
1988
- it('⑥ 自检行计数 = 本次实扫结果(不读 doctor 缓存)+ 去重根注记', async () => {
2155
+ it('⑥ 自检行计数 = 本次实扫结果 + 去重根注记', async () => {
1989
2156
  const agentDir = join(tmp, 'agent')
1990
- // PS-14 形态:doctor 首跑时 main 根空 → 缓存 fileCount=0
2157
+ // 根扫描无缓存(doctor 缓存机已删,ext-simplify-04 U3)——doctor 首跑后落盘的
2158
+ // 文件,find 自检行照实扫计数(原 PS-14「doctor 缓存 0 文件」形态结构性不存在)
1991
2159
  await mkdir(join(agentDir, 'sessions'), { recursive: true })
1992
2160
  await handleSessionRead({ action: 'doctor' }, { agentDir })
1993
2161
  // 之后 session 落盘(main 2 + subagent 1)
@@ -2000,7 +2168,6 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
2000
2168
  { agentDir },
2001
2169
  )
2002
2170
  const text = r.content[0].text
2003
- // 若 F1 读 doctor 缓存(0 文件),以下计数断言必红
2004
2171
  expect(text).toContain(`${join(agentDir, 'sessions')}:2 文件`)
2005
2172
  expect(text).toContain(`${join(agentDir, 'subagents')}:1 文件`)
2006
2173
  expect(text).toContain('候选集非空(共 3 文件)')
@@ -2072,7 +2239,7 @@ describe('片段解析接通 [live] 根(fixture,同一 roots 契约)', ()
2072
2239
  // 负向对照:不传 liveSessionDir(三根降级)→ 同一片段 F1 零匹配
2073
2240
  //([default]/[legacy]/[subagent] 均无此文件——证明命中确实来自 [live] 根)
2074
2241
  await expect(
2075
- handleSessionRead({ action: 'outline', session: FRAGMENT }, agentDir),
2242
+ handleSessionRead({ action: 'outline', session: FRAGMENT }, { agentDir }),
2076
2243
  ).rejects.toThrow(/无匹配 session/)
2077
2244
  })
2078
2245
 
@@ -2176,7 +2343,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2176
2343
  query: string,
2177
2344
  extra?: Partial<SessionReadParams>,
2178
2345
  ): ReturnType<typeof handleSessionRead> {
2179
- return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
2346
+ return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
2180
2347
  }
2181
2348
 
2182
2349
  interface FindDetails {
@@ -2304,7 +2471,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2304
2471
 
2305
2472
  const res = await handleSessionRead(
2306
2473
  { action: 'result', session: 'sa-u10-r1,sa-u10-r2' },
2307
- join(tmp, 'agent'),
2474
+ { agentDir: join(tmp, 'agent') },
2308
2475
  )
2309
2476
  const text = res.content[0].text
2310
2477
  expect(text).toContain(`(session ${R1.slice(0, 8)}…)`)
@@ -2331,7 +2498,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2331
2498
 
2332
2499
  // 解析路径(resolveByFragment → findSessions limit:10 无 source)保持 mtime 排序 +
2333
2500
  // limit 截断原语义:5 命中全进 F2 消歧候选,无 main 置顶/配额分组介入(§6.7 末条)
2334
- const r = await handleSessionRead({ action: 'outline', session: PREFIX }, join(tmp, 'agent'))
2501
+ const r = await handleSessionRead({ action: 'outline', session: PREFIX }, { agentDir: join(tmp, 'agent') })
2335
2502
  const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
2336
2503
  expect(d.ambiguous).toBe(true)
2337
2504
  expect(d.candidates).toHaveLength(5)
@@ -2355,15 +2522,113 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2355
2522
  })
2356
2523
  })
2357
2524
 
2525
+ // ============================================================
2526
+ // E1:find 单次根解析(ext-simplify-04 U2,探针 P1)。spy 打在 roots 模块命名空间
2527
+ // 上跨模块拦截(find.ts / findNoMatch 渲染链的消费点全走同一入口),断言 doFind
2528
+ // 全路径(分组 / 显式 source / 零匹配)resolveSessionRoots 恰调用 1 次——改前分组
2529
+ // 2 次、零匹配 3 次(impl-plan D-15④ 登记的分组双倍实扫债),预解析根复用后恒 1 次。
2530
+ // ============================================================
2531
+ describe('E1 find 单次根解析(探针 P1:resolveSessionRoots 恰 1 次)', () => {
2532
+ let tmp: string
2533
+ const SLUG = '--e1-cwd--'
2534
+ const PREFIX = '019e7e10'
2535
+ const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
2536
+ const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
2537
+
2538
+ /** 写 session 文件(subagent:true 写入 subagent 根),形态同 u10 段 writeSession。 */
2539
+ async function writeSession(id: string, opts?: { subagent?: boolean }): Promise<void> {
2540
+ const dir = opts?.subagent
2541
+ ? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
2542
+ : join(tmp, 'agent', 'sessions', SLUG)
2543
+ await mkdir(dir, { recursive: true })
2544
+ await writeFile(
2545
+ join(dir, `${id}.jsonl`),
2546
+ JSON.stringify({ type: 'session', id, cwd: '/e1' }) + '\n',
2547
+ )
2548
+ }
2549
+
2550
+ function find(
2551
+ query: string,
2552
+ extra?: Partial<SessionReadParams>,
2553
+ ): ReturnType<typeof handleSessionRead> {
2554
+ return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
2555
+ }
2556
+
2557
+ /** spy 计数器 + 断言 helper:包装一段 find 调用,返回 { 结果, 调用次数 }。 */
2558
+ async function findCounting(
2559
+ query: string,
2560
+ extra?: Partial<SessionReadParams>,
2561
+ ): Promise<{ result: Awaited<ReturnType<typeof handleSessionRead>>; resolveCalls: number }> {
2562
+ const rootsModule = await import('../discovery/roots.js')
2563
+ const spy = vi.spyOn(rootsModule, 'resolveSessionRoots')
2564
+ try {
2565
+ const result = await find(query, extra)
2566
+ return { result, resolveCalls: spy.mock.calls.length }
2567
+ } finally {
2568
+ spy.mockRestore()
2569
+ }
2570
+ }
2571
+
2572
+ beforeEach(async () => {
2573
+ tmp = await mkdtemp(join(tmpdir(), 'tool-handler-e1-'))
2574
+ })
2575
+ afterEach(async () => {
2576
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2577
+ })
2578
+
2579
+ it('① 分组路径(main + subagent 两段查询,有命中):恰 1 次', async () => {
2580
+ await writeSession(mainId(1))
2581
+ await writeSession(subId(1), { subagent: true })
2582
+ const { result, resolveCalls } = await findCounting(PREFIX)
2583
+ const d = result.details as { matches: Array<{ source: string }>; truncated: boolean }
2584
+ // 前置:两段确实各自命中(分组语义不变),再断言扫描收敛
2585
+ expect(d.matches).toHaveLength(2)
2586
+ expect(new Set(d.matches.map((m) => m.source))).toEqual(new Set(['main', 'subagent']))
2587
+ expect(d.truncated).toBe(false)
2588
+ expect(resolveCalls).toBe(1)
2589
+ })
2590
+
2591
+ it('② 显式 source 路径(单组查询,有命中):恰 1 次', async () => {
2592
+ await writeSession(mainId(1))
2593
+ await writeSession(subId(1), { subagent: true })
2594
+ const { result, resolveCalls } = await findCounting(PREFIX, { source: 'subagent' })
2595
+ const d = result.details as { matches: Array<{ source: string }> }
2596
+ expect(d.matches).toHaveLength(1)
2597
+ expect(d.matches[0].source).toBe('subagent')
2598
+ expect(resolveCalls).toBe(1)
2599
+ })
2600
+
2601
+ it('③ 分组零匹配 → F1 自检行:恰 1 次(改前 3 次)且根表仍进渲染', async () => {
2602
+ await writeSession(mainId(1))
2603
+ const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x')
2604
+ const d = result.details as { matches: unknown[]; truncated: boolean }
2605
+ expect(d.matches).toEqual([])
2606
+ expect(d.truncated).toBe(false)
2607
+ // F1 自检行消费预解析根表(非空根计数可见 = roots 确实传入而非空列表)
2608
+ expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
2609
+ expect(result.content[0].text).toContain('候选集非空')
2610
+ expect(resolveCalls).toBe(1)
2611
+ })
2612
+
2613
+ it('④ 显式 source 零匹配 → F1 自检行:恰 1 次(改前 2 次)', async () => {
2614
+ await writeSession(subId(1), { subagent: true })
2615
+ const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x', { source: 'main' })
2616
+ expect(result.content[0].text).toContain('无匹配 session')
2617
+ expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
2618
+ expect(resolveCalls).toBe(1)
2619
+ })
2620
+ })
2621
+
2358
2622
  // ============================================================
2359
2623
  // u11:metadataProvider 注入 + TTL 缓存(design 2026-09-10 §6.6)
2360
- // handler 级集成:注入包装(withMetadataCache 独立实例,不与 doctorScanCache 串扰)
2624
+ // handler 级集成:注入包装(metadata 缓存独立实例——根扫描无缓存,doctor 缓存机
2625
+ // 已删除,ext-simplify-04 U3,不存在串扰面)
2361
2626
  // + 标题渲染(§5.1 候选行标题优先)+ provider 抛错降级不外抛。
2362
2627
  // 策略①惰性/②窄化/合并语义在 find.test.ts u11 段覆盖。
2363
2628
  // ============================================================
2364
2629
  describe('u11 metadataProvider 注入 + TTL 缓存(fixture,§6.6)', () => {
2365
2630
  let tmp: string
2366
- /** 平铺 main 根(xyz-agent 形态),同时作 liveSessionDir(与 [default] 根同字面路径) */
2631
+ /** 平铺 main 根(taiji 形态),同时作 liveSessionDir(与 [default] 根同字面路径) */
2367
2632
  let flatMain: string
2368
2633
 
2369
2634
  async function writeSession(
@@ -2567,7 +2832,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2567
2832
  const ids = Array.from({ length: MULTI_SEARCH_MAX_SESSIONS + 1 }, (_, i) => mainId(i + 10))
2568
2833
  const r = await handleSessionRead(
2569
2834
  { action: 'search', session: ids.join(','), pattern: 'adj_factor' },
2570
- join(tmp, 'agent'),
2835
+ { agentDir: join(tmp, 'agent') },
2571
2836
  )
2572
2837
  const text = r.content[0].text
2573
2838
  expect(text).toContain('跨会话检索已拒绝')
@@ -2697,7 +2962,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2697
2962
  await writeSession(A, [{ role: 'user', text: '普通内容' }])
2698
2963
  const r = await handleSessionRead(
2699
2964
  { action: 'search', session: `${A},${GHOST}`, pattern: 'zzznothing' },
2700
- join(tmp, 'agent'),
2965
+ { agentDir: join(tmp, 'agent') },
2701
2966
  )
2702
2967
  const text = r.content[0].text
2703
2968
  expect(text).toContain('0/1 session(s) hit for /zzznothing/')
@@ -2721,7 +2986,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2721
2986
  // 单 id:原 details.hits 结构与 header 形态不变
2722
2987
  const r = await handleSessionRead(
2723
2988
  { action: 'search', session: A, pattern: 'plugin' },
2724
- join(tmp, 'agent'),
2989
+ { agentDir: join(tmp, 'agent') },
2725
2990
  )
2726
2991
  expect(r.content[0].text).toContain('1 hit(s) for /plugin/')
2727
2992
  const d = r.details as { hits: unknown[]; truncated: boolean; scanned?: unknown }
@@ -2731,7 +2996,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2731
2996
  // pattern 含逗号不触发跨会话(分流只看 session)
2732
2997
  const r2 = await handleSessionRead(
2733
2998
  { action: 'search', session: A, pattern: 'aaa,bbb' },
2734
- join(tmp, 'agent'),
2999
+ { agentDir: join(tmp, 'agent') },
2735
3000
  )
2736
3001
  expect((r2.details as { hits: unknown[] }).hits).toHaveLength(0)
2737
3002
  expect((r2.details as { scanned?: unknown }).scanned).toBeUndefined()