@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.
@@ -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,
@@ -46,14 +47,14 @@ import {
46
47
  // vitest 默认 5s(pnpm extensions:test 全量跑时多包集成测试并发 IO),显式放宽到 60s。
47
48
  describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
48
49
  it('1. find by uuid fragment returns matching session', async () => {
49
- const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, REAL)
50
+ const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir: REAL })
50
51
  const d = r.details as { matches: Array<{ sessionId: string }> }
51
52
  expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
52
53
  expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
53
54
  })
54
55
 
55
56
  it('2. outline yields 32 turns within token budget (v2 O1: <=1500)', async () => {
56
- const r = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
57
+ const r = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
57
58
  const d = r.details as { turns: unknown[]; tokenEstimate: number }
58
59
  expect(d.turns.length).toBe(32)
59
60
  // v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 600→1500(design §3.3 D4)
@@ -61,7 +62,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
61
62
  })
62
63
 
63
64
  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)
65
+ const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, { agentDir: REAL })
65
66
  const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
66
67
  expect(d.entries.length).toBeGreaterThan(0)
67
68
  // v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
@@ -76,7 +77,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
76
77
  // 而非失败——与 TC14-TC18 的「数据不存在则 return」守卫模式一致,避免偶发红。
77
78
  // 注意必须用 hasAnyRealSession(双目录),hasRealSession 只扫 sessions/ 扫不到 019fe635。
78
79
  if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
79
- const r = await handleSessionRead({ action: 'family', session: FAM }, REAL)
80
+ const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: REAL })
80
81
  const d = r.details as {
81
82
  forks: Array<{ sessionId: string }>
82
83
  subagents: Array<{ sessionId: string }>
@@ -87,7 +88,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
87
88
  })
88
89
 
89
90
  it('5. search pattern returns hits', async () => {
90
- const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, REAL)
91
+ const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, { agentDir: REAL })
91
92
  const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
92
93
  expect(d.hits.length).toBeGreaterThan(0)
93
94
  expect(typeof d.hits[0].matchSnippet).toBe('string')
@@ -96,7 +97,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
96
97
  it('6. export outline materializes a .md file', async () => {
97
98
  const r = await handleSessionRead(
98
99
  { action: 'export', session: E6, format: 'outline' },
99
- REAL,
100
+ { agentDir: REAL },
100
101
  )
101
102
  const d = r.details as { path: string; sizeBytes: number }
102
103
  expect(d.path).toMatch(/\.md$/)
@@ -111,7 +112,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
111
112
  it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
112
113
  const r = await handleSessionRead(
113
114
  { action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
114
- REAL,
115
+ { agentDir: REAL },
115
116
  )
116
117
  const d = r.details as { matches: unknown[]; truncated: boolean }
117
118
  expect(d.matches).toEqual([])
@@ -125,17 +126,17 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
125
126
 
126
127
  it('8. F4 detail turn out of range throws with 越界', async () => {
127
128
  await expect(
128
- handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, REAL),
129
+ handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, { agentDir: REAL }),
129
130
  ).rejects.toThrow(/越界/)
130
131
  })
131
132
 
132
133
  it('9. F5 outline missing session throws naming session', async () => {
133
- await expect(handleSessionRead({ action: 'outline' }, REAL)).rejects.toThrow(/session/)
134
+ await expect(handleSessionRead({ action: 'outline' }, { agentDir: REAL })).rejects.toThrow(/session/)
134
135
  })
135
136
 
136
137
  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)
138
+ const rFull = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
139
+ const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: REAL })
139
140
  const dFull = rFull.details as { turns: unknown[] }
140
141
  const dFrag = rFrag.details as { turns: unknown[] }
141
142
  expect(dFrag.turns.length).toBe(dFull.turns.length)
@@ -146,7 +147,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
146
147
  it('user-messages returns 26 user entries with turn + full text', async () => {
147
148
  const r = await handleSessionRead(
148
149
  { action: 'extract', session: E6, what: 'user-messages' },
149
- REAL,
150
+ { agentDir: REAL },
150
151
  )
151
152
  const d = r.details as {
152
153
  what: string
@@ -169,7 +170,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
169
170
  it('commands (no filter) returns 519 tool calls with name + summary', async () => {
170
171
  const r = await handleSessionRead(
171
172
  { action: 'extract', session: E6, what: 'commands' },
172
- REAL,
173
+ { agentDir: REAL },
173
174
  )
174
175
  const d = r.details as {
175
176
  count: number
@@ -192,7 +193,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
192
193
  it('commands tool=bash returns 309 all bash', async () => {
193
194
  const r = await handleSessionRead(
194
195
  { action: 'extract', session: E6, what: 'commands', tool: 'bash' },
195
- REAL,
196
+ { agentDir: REAL },
196
197
  )
197
198
  const d = r.details as {
198
199
  count: number
@@ -206,7 +207,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
206
207
  it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
207
208
  const r = await handleSessionRead(
208
209
  { action: 'extract', session: E6, what: 'commands', tool: 'nonexist' },
209
- REAL,
210
+ { agentDir: REAL },
210
211
  )
211
212
  const d = r.details as {
212
213
  toolDistribution: Array<{ name: string; count: number }>
@@ -220,7 +221,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
220
221
  it('files returns deduped paths with op (read/edit/write/head)', async () => {
221
222
  const r = await handleSessionRead(
222
223
  { action: 'extract', session: E6, what: 'files' },
223
- REAL,
224
+ { agentDir: REAL },
224
225
  )
225
226
  const d = r.details as {
226
227
  count: number
@@ -241,7 +242,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
241
242
  it('commits returns hash list with 7-8 hex + source turn', async () => {
242
243
  const r = await handleSessionRead(
243
244
  { action: 'extract', session: E6, what: 'commits' },
244
- REAL,
245
+ { agentDir: REAL },
245
246
  )
246
247
  const d = r.details as {
247
248
  count: number
@@ -260,7 +261,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
260
261
  it('tool-results (no filter) returns 515 results with toolName + text', async () => {
261
262
  const r = await handleSessionRead(
262
263
  { action: 'extract', session: E6, what: 'tool-results' },
263
- REAL,
264
+ { agentDir: REAL },
264
265
  )
265
266
  const d = r.details as {
266
267
  count: number
@@ -277,7 +278,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
277
278
  it('tool-results tool=bash returns 309 all bash', async () => {
278
279
  const r = await handleSessionRead(
279
280
  { action: 'extract', session: E6, what: 'tool-results', tool: 'bash' },
280
- REAL,
281
+ { agentDir: REAL },
281
282
  )
282
283
  const d = r.details as { count: number; items: Array<{ toolName: string }> }
283
284
  expect(d.count).toBe(309)
@@ -287,7 +288,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
287
288
  it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
288
289
  const r = await handleSessionRead(
289
290
  { action: 'extract', session: E6, what: 'tool-results', tool: 'nonexist' },
290
- REAL,
291
+ { agentDir: REAL },
291
292
  )
292
293
  expect(r.content[0].text).toContain('无匹配')
293
294
  expect(r.content[0].text).toContain('bash×309')
@@ -297,8 +298,8 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
297
298
  // what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
298
299
  // isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
299
300
  const params: SessionReadParams = { action: 'extract', session: E6 }
300
- await expect(handleSessionRead(params, REAL)).rejects.toThrow(/无效/)
301
- await expect(handleSessionRead(params, REAL)).rejects.toThrow(
301
+ await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(/无效/)
302
+ await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(
302
303
  /user-messages\/commands\/files\/commits\/tool-results/,
303
304
  )
304
305
  })
@@ -309,7 +310,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
309
310
  try {
310
311
  await handleSessionRead(
311
312
  { action: 'extract', session: E6, what: 'user-messages', turns: 'T999' },
312
- REAL,
313
+ { agentDir: REAL },
313
314
  )
314
315
  } catch (e) {
315
316
  msg = (e as Error).message
@@ -358,7 +359,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
358
359
  await makeFixtureSession(dir, ID2, '第二段内容')
359
360
 
360
361
  // outline 走 resolveSessionId → 2 匹配 → F2 消歧(不抛错)
361
- const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, dir)
362
+ const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, { agentDir: dir })
362
363
  const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
363
364
  expect(d.ambiguous).toBe(true)
364
365
  expect(d.candidates).toHaveLength(2)
@@ -382,7 +383,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
382
383
  if (action === 'expand') params.turn = 'T001'
383
384
  if (action === 'search') params.pattern = 'x'
384
385
  if (action === 'extract') params.what = 'user-messages'
385
- const r = await handleSessionRead(params, dir)
386
+ const r = await handleSessionRead(params, { agentDir: dir })
386
387
  const d = r.details as { ambiguous: boolean }
387
388
  expect(d.ambiguous, `action=${action}`).toBe(true)
388
389
  expect(r.content[0].text).toContain('👉')
@@ -417,7 +418,7 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
417
418
  ]
418
419
  await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
419
420
 
420
- const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
421
+ const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
421
422
  const d = r.details as { stats: { skippedLines: number } }
422
423
  expect(d.stats.skippedLines).toBe(1)
423
424
  expect(r.content[0].text).toContain('1 skipped lines')
@@ -425,13 +426,84 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
425
426
 
426
427
  it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
427
428
  await makeFixtureSession(dir, SID, 'clean session')
428
- const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
429
+ const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
429
430
  const d = r.details as { stats: { skippedLines: number } }
430
431
  expect(d.stats.skippedLines).toBe(0)
431
432
  expect(r.content[0].text).not.toContain('skipped lines')
432
433
  })
433
434
  })
434
435
 
436
+ // ============================================================
437
+ // E7:行渲染统一(ext-simplify-04 U5,design D3)。outline 与 export {format:"outline"}
438
+ // 共用 renderOutline 返回的渲染行(OutlineResult.lines)+ 同一尾段拼装——预算度量 =
439
+ // 展示 by construction;旁支标记统一为 `[旁支 N entries]`(改前 handler 侧第二份行
440
+ // 格式知识输出裸 `[旁支]`,已随 formatOutlineText 删除)。
441
+ // ============================================================
442
+ describe('E7 行渲染统一(outline 与 export outline 同一渲染行)', () => {
443
+ let dir: string
444
+ const SID = '019e6c96-bbbb-cccc-dddd-00000000000e'
445
+
446
+ /** 写含 fork 旁支的 session:主链 R→m1(user)→m2(assistant),b1/b2 挂 m1 下成
447
+ * 旁支子树(buildTreeView:branches[m1]=2;segmentTurns:单 turn [m1, m2],
448
+ * 旁支 entry 不进 leaf 视图)。 */
449
+ async function writeForkSession(): Promise<void> {
450
+ await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
451
+ const msg = (id: string, parentId: string, role: 'user' | 'assistant', text: string) =>
452
+ JSON.stringify({
453
+ type: 'message',
454
+ id: `${SID}-${id}`,
455
+ parentId,
456
+ message: { role, content: [{ type: 'text', text }] },
457
+ })
458
+ const lines = [
459
+ JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
460
+ msg('m1', SID, 'user', 'fork point turn'),
461
+ msg('b1', `${SID}-m1`, 'assistant', 'branch one'),
462
+ msg('b2', `${SID}-m1`, 'assistant', 'branch two'),
463
+ msg('m2', `${SID}-m1`, 'assistant', 'main line answer'),
464
+ ]
465
+ await writeFile(join(dir, 'sessions', '--demo-cwd--', `${SID}.jsonl`), lines.join('\n') + '\n')
466
+ }
467
+
468
+ beforeEach(async () => {
469
+ dir = await mkdtemp(join(tmpdir(), 'tool-handler-e7-'))
470
+ })
471
+ afterEach(async () => {
472
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
473
+ })
474
+
475
+ it('① allBranches:true → 旁支行标记 `[旁支 N entries]`(N=forkPoint 旁支子树 entry 数)+ 尾段同改前语义', async () => {
476
+ await writeForkSession()
477
+ const r = await handleSessionRead(
478
+ { action: 'outline', session: SID, allBranches: true },
479
+ { agentDir: dir },
480
+ )
481
+ const text = r.content[0].text
482
+ // 旁支标记带 entry 数;裸 `[旁支]` 旧形态零出现('[旁支]' 与 '[旁支 2 entries]' 互不为子串)
483
+ expect(text).toContain('T000 · fork point turn · → main line answer · [旁支 2 entries]')
484
+ expect(text).not.toContain('[旁支]')
485
+ // stats 尾段与改前语义一致(totalEntries = leaf 2 + 旁支 2)
486
+ expect(text).toContain('1 turns · 4 entries · ')
487
+ })
488
+
489
+ it('② 两 action 一致性:export {format:"outline"} 物化全文 === outline content 文本(旁支行同形态)', async () => {
490
+ await writeForkSession()
491
+ const outline = await handleSessionRead(
492
+ { action: 'outline', session: SID, allBranches: true },
493
+ { agentDir: dir },
494
+ )
495
+ const exp = await handleSessionRead(
496
+ { action: 'export', session: SID, format: 'outline', allBranches: true },
497
+ { agentDir: dir },
498
+ )
499
+ const d = exp.details as { path: string }
500
+ const exported = await readFile(d.path, 'utf8')
501
+ // 干净 session(无坏行)下两 action 全文逐字节一致——同一 result.lines + 同一尾段拼装
502
+ expect(exported).toBe(outline.content[0].text)
503
+ expect(exported).toContain('[旁支 2 entries]')
504
+ })
505
+ })
506
+
435
507
  describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
436
508
  let dir: string
437
509
  const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
@@ -445,21 +517,21 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
445
517
  })
446
518
 
447
519
  it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
448
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
520
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
449
521
  const d = r.details as { hits: unknown[] }
450
522
  // 字面量 '(a+)+' 不在内容里 → 0 命中(若按正则执行会命中 'aaa' 且可能指数回溯)
451
523
  expect(d.hits).toHaveLength(0)
452
524
  })
453
525
 
454
526
  it('普通正则仍按正则匹配', async () => {
455
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, dir)
527
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, { agentDir: dir })
456
528
  const d = r.details as { hits: unknown[] }
457
529
  expect(d.hits.length).toBeGreaterThan(0)
458
530
  })
459
531
 
460
532
  it('范围量词 {m,n} 形态 → 降级字面子串((a{1,3})*、(a{0,2})*、(a{1,3}){2,},MF-1 回归)', async () => {
461
533
  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)
534
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
463
535
  const d = r.details as { hits: unknown[] }
464
536
  // 字面量不含这些 pattern → 0 命中;若按正则执行会指数回溯挂死
465
537
  expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
@@ -468,7 +540,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
468
540
 
469
541
  it('alternation/嵌套量词分支 (a|aa)+、(a*)* → 降级字面子串(MF-5 分支回归)', async () => {
470
542
  for (const pattern of ['(a|aa)+', '(a*)*']) {
471
- const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
543
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
472
544
  const d = r.details as { hits: unknown[] }
473
545
  expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
474
546
  }
@@ -477,7 +549,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
477
549
  it('(a{1,3}) 单独使用不被降级(组后无尾随量词,仍按正则执行)', async () => {
478
550
  const r = await handleSessionRead(
479
551
  { action: 'search', session: SID, pattern: '(a{1,3})' },
480
- dir,
552
+ { agentDir: dir },
481
553
  )
482
554
  const d = r.details as { hits: unknown[] }
483
555
  // 按正则执行命中 'aaa' → >0 命中(若被降级为字面量则 0 命中)
@@ -485,12 +557,12 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
485
557
  })
486
558
 
487
559
  it('降级标注:header 含「已降级为字面子串匹配」(S-3)', async () => {
488
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
560
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
489
561
  expect(r.content[0].text).toContain('已降级为字面子串匹配')
490
562
  })
491
563
 
492
564
  it('非法正则 pattern → 字面子串兜底不抛错(零命中,S-6)', async () => {
493
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, dir)
565
+ const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, { agentDir: dir })
494
566
  const d = r.details as { hits: unknown[] }
495
567
  expect(d.hits).toHaveLength(0)
496
568
  })
@@ -498,7 +570,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
498
570
  it('scope 过滤:scope=assistant 零命中(fixture 仅 user 角色,S-6)', async () => {
499
571
  const r = await handleSessionRead(
500
572
  { action: 'search', session: SID, pattern: 'aaa', scope: 'assistant' },
501
- dir,
573
+ { agentDir: dir },
502
574
  )
503
575
  const d = r.details as { hits: unknown[] }
504
576
  expect(d.hits).toHaveLength(0)
@@ -525,7 +597,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
525
597
  )
526
598
  const r = await handleSessionRead(
527
599
  { action: 'search', session: sid2, pattern: 'aa', limit: 2 },
528
- dir,
600
+ { agentDir: dir },
529
601
  )
530
602
  const d = r.details as { hits: unknown[]; truncated: boolean }
531
603
  expect(d.truncated).toBe(true)
@@ -536,7 +608,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
536
608
  const ac = new AbortController()
537
609
  ac.abort()
538
610
  await expect(
539
- handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, dir, ac.signal),
611
+ handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, { agentDir: dir }, ac.signal),
540
612
  ).rejects.toThrow(/中断/)
541
613
  })
542
614
  })
@@ -611,7 +683,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
611
683
  },
612
684
  }),
613
685
  ])
614
- const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
686
+ const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
615
687
  const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
616
688
  const hashes = d.items.map((it) => it.hash)
617
689
  expect(hashes).toContain('abc1234f')
@@ -669,7 +741,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
669
741
  },
670
742
  }),
671
743
  ])
672
- const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
744
+ const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
673
745
  const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
674
746
  const dupes = d.items.filter((it) => it.hash === 'def5678a')
675
747
  expect(dupes).toHaveLength(1)
@@ -811,7 +883,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
811
883
  // export 的 details.path = session-view-<sessionId>.md,含 header 真实 id,不含文件名
812
884
  const r = await handleSessionRead(
813
885
  { action: 'export', session: filePath, format: 'outline' },
814
- dir,
886
+ { agentDir: dir },
815
887
  )
816
888
  const d = r.details as { path: string }
817
889
  expect(d.path).toContain(fileId)
@@ -826,10 +898,10 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
826
898
  `not-exist-${Date.now()}.jsonl`,
827
899
  )
828
900
  await expect(
829
- handleSessionRead({ action: 'outline', session: filePath }, dir),
901
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
830
902
  ).rejects.toThrow(/读取失败.*文件不存在/)
831
903
  await expect(
832
- handleSessionRead({ action: 'outline', session: filePath }, dir),
904
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
833
905
  ).rejects.toThrow('👉')
834
906
  })
835
907
 
@@ -838,7 +910,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
838
910
  await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
839
911
  await writeFile(filePath, 'not jsonl')
840
912
  await expect(
841
- handleSessionRead({ action: 'outline', session: filePath }, dir),
913
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
842
914
  ).rejects.toThrow(/读取失败.*非 \.jsonl/)
843
915
  })
844
916
 
@@ -847,7 +919,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
847
919
  await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
848
920
  await writeFile(filePath, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
849
921
  await expect(
850
- handleSessionRead({ action: 'outline', session: filePath }, dir),
922
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
851
923
  ).rejects.toThrow(/读取失败.*首行非合法 session header/)
852
924
  })
853
925
 
@@ -856,7 +928,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
856
928
  await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
857
929
  await writeFile(filePath, '')
858
930
  await expect(
859
- handleSessionRead({ action: 'outline', session: filePath }, dir),
931
+ handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
860
932
  ).rejects.toThrow(/读取失败.*首行非合法 session header/)
861
933
  })
862
934
 
@@ -874,7 +946,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
874
946
  const tildePath = '~/' + sessionFile.slice(home.length + 1)
875
947
  const r = await handleSessionRead(
876
948
  { action: 'export', session: tildePath, format: 'outline' },
877
- dir,
949
+ { agentDir: dir },
878
950
  )
879
951
  expect((r.details as { path: string }).path).toContain(fileId)
880
952
  } finally {
@@ -898,7 +970,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
898
970
  await makeFixtureSubagent(dir, 'sa-aaa', { realSessionId: realId, firstUserText: 'do task' })
899
971
  const r = await handleSessionRead(
900
972
  { action: 'export', session: 'sa-aaa', format: 'outline' },
901
- dir,
973
+ { agentDir: dir },
902
974
  )
903
975
  const d = r.details as { path: string }
904
976
  expect(d.path).toContain(realId)
@@ -913,11 +985,11 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
913
985
  sessionFileExists: false,
914
986
  })
915
987
  await expect(
916
- handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir),
988
+ handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir }),
917
989
  ).rejects.toThrow('session 文件不存在')
918
990
  // 错误含 manifest 全部元数据 + 👉
919
991
  try {
920
- await handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir)
992
+ await handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir })
921
993
  } catch (e) {
922
994
  const msg = (e as Error).message
923
995
  expect(msg).toContain('sa-gc')
@@ -931,10 +1003,10 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
931
1003
 
932
1004
  it('TC9: sa-id 0 命中(可能 running)→ ES2(含 family 指引 + 完整 id 提示 + 👉)', async () => {
933
1005
  await expect(
934
- handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir),
1006
+ handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
935
1007
  ).rejects.toThrow('无匹配 record')
936
1008
  try {
937
- await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
1009
+ await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
938
1010
  } catch (e) {
939
1011
  const msg = (e as Error).message
940
1012
  expect(msg).toContain('可能尚未落盘')
@@ -949,7 +1021,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
949
1021
  })
950
1022
  // 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中 → ES2
951
1023
  await expect(
952
- handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, dir),
1024
+ handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
953
1025
  ).rejects.toThrow('无匹配 record')
954
1026
  })
955
1027
 
@@ -978,12 +1050,12 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
978
1050
  )
979
1051
  }
980
1052
  await expect(
981
- handleSessionRead({ action: 'outline', session: 'sa-dup' }, dir),
1053
+ handleSessionRead({ action: 'outline', session: 'sa-dup' }, { agentDir: dir }),
982
1054
  ).rejects.toThrow(/匹配 2 个 record.*数据异常/)
983
1055
  })
984
1056
 
985
1057
  it('CQ3: sa-id 命中但 sessionFile header 读不出 → F6 风格(不降级 record.id 当 sessionId)', async () => {
986
- // sessionFile 存在但首行非 session header → readSessionHeaderId 返 undefined
1058
+ // sessionFile 存在但首行非 session header → readSessionHeaderIdSync 返 undefined
987
1059
  const slug = '--demo-cwd--'
988
1060
  const sessionFile = join(dir, 'subagents', slug, 'sessions', 'bad.jsonl')
989
1061
  await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
@@ -1001,7 +1073,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
1001
1073
  )
1002
1074
  // 抛 F6 风格(读取失败 + 首行非合法 session header),不降级返回 sa-bad 当 sessionId
1003
1075
  await expect(
1004
- handleSessionRead({ action: 'outline', session: 'sa-bad' }, dir),
1076
+ handleSessionRead({ action: 'outline', session: 'sa-bad' }, { agentDir: dir }),
1005
1077
  ).rejects.toThrow(/读取失败.*首行非合法 session header/)
1006
1078
  })
1007
1079
  })
@@ -1026,7 +1098,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1026
1098
  // 无 source → 两者
1027
1099
  const rBoth = await handleSessionRead(
1028
1100
  { action: 'find', query: sharedFragment },
1029
- dir,
1101
+ { agentDir: dir },
1030
1102
  )
1031
1103
  const dBoth = rBoth.details as {
1032
1104
  matches: Array<{ source: string; sessionId: string }>
@@ -1037,7 +1109,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1037
1109
  // source:subagent → 只 sub
1038
1110
  const rSub = await handleSessionRead(
1039
1111
  { action: 'find', query: sharedFragment, source: 'subagent' },
1040
- dir,
1112
+ { agentDir: dir },
1041
1113
  )
1042
1114
  const dSub = rSub.details as {
1043
1115
  matches: Array<{ source: string; sessionId: string }>
@@ -1049,7 +1121,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1049
1121
  // source:main → 只 main
1050
1122
  const rMain = await handleSessionRead(
1051
1123
  { action: 'find', query: sharedFragment, source: 'main' },
1052
- dir,
1124
+ { agentDir: dir },
1053
1125
  )
1054
1126
  const dMain = rMain.details as {
1055
1127
  matches: Array<{ source: string; sessionId: string }>
@@ -1069,14 +1141,14 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1069
1141
  // 无 source → main+sub 共享片段 → 2 匹配 → F2 消歧
1070
1142
  const rMulti = await handleSessionRead(
1071
1143
  { action: 'outline', session: sharedFragment },
1072
- dir,
1144
+ { agentDir: dir },
1073
1145
  )
1074
1146
  expect((rMulti.details as { ambiguous: boolean }).ambiguous).toBe(true)
1075
1147
 
1076
1148
  // source:main → 收窄到 main → 唯一匹配 → outline 成功,且是 main(export path 含 mainId)
1077
1149
  const rExp = await handleSessionRead(
1078
1150
  { action: 'export', session: sharedFragment, source: 'main', format: 'outline' },
1079
- dir,
1151
+ { agentDir: dir },
1080
1152
  )
1081
1153
  expect((rExp.details as { path: string }).path).toContain(mainId)
1082
1154
  })
@@ -1087,7 +1159,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1087
1159
  const manifests = await listRecordManifests(REAL)
1088
1160
  const alive = manifests.filter((m) => existsSync(m.sessionFile))
1089
1161
  if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
1090
- const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, REAL)
1162
+ const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, { agentDir: REAL })
1091
1163
  const d = r.details as { turns: unknown[] }
1092
1164
  expect(d.turns.length).toBeGreaterThan(0)
1093
1165
  })
@@ -1098,7 +1170,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1098
1170
  (m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
1099
1171
  )
1100
1172
  if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
1101
- const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, REAL)
1173
+ const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, { agentDir: REAL })
1102
1174
  expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1103
1175
  })
1104
1176
 
@@ -1109,7 +1181,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1109
1181
  // 绝对路径形态直接读(M0 入口不依赖 findSessions)
1110
1182
  const r = await handleSessionRead(
1111
1183
  { action: 'outline', session: alive[0].sessionFile },
1112
- REAL,
1184
+ { agentDir: REAL },
1113
1185
  )
1114
1186
  expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1115
1187
  })
@@ -1119,14 +1191,14 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1119
1191
  const gc = manifests.filter((m) => !existsSync(m.sessionFile))
1120
1192
  if (gc.length === 0) return // 本机无 GC 数据则跳过
1121
1193
  await expect(
1122
- handleSessionRead({ action: 'outline', session: gc[0].id }, REAL),
1194
+ handleSessionRead({ action: 'outline', session: gc[0].id }, { agentDir: REAL }),
1123
1195
  ).rejects.toThrow('session 文件不存在')
1124
1196
  })
1125
1197
 
1126
1198
  it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
1127
1199
  const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
1128
1200
  await expect(
1129
- handleSessionRead({ action: 'outline', session: fakeId }, REAL),
1201
+ handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
1130
1202
  ).rejects.toThrow('无匹配 record')
1131
1203
  })
1132
1204
  }, 60000)
@@ -1258,7 +1330,7 @@ describe('doWorkflow(w6,fixture)', () => {
1258
1330
  ])
1259
1331
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-single-1', path: wfPath })
1260
1332
 
1261
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1333
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1262
1334
  const d = r.details as {
1263
1335
  runs: Array<{
1264
1336
  runId: string
@@ -1296,7 +1368,7 @@ describe('doWorkflow(w6,fixture)', () => {
1296
1368
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-1', path: wf1 })
1297
1369
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-2', path: wf2 })
1298
1370
 
1299
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1371
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1300
1372
  const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
1301
1373
  expect(d.runs).toHaveLength(2)
1302
1374
  expect(d.runIds).toHaveLength(2)
@@ -1321,7 +1393,7 @@ describe('doWorkflow(w6,fixture)', () => {
1321
1393
 
1322
1394
  const r = await handleSessionRead(
1323
1395
  { action: 'workflow', session: WF_ROOT, runId: 'wf-filter-1' },
1324
- dir,
1396
+ { agentDir: dir },
1325
1397
  )
1326
1398
  const d = r.details as {
1327
1399
  runs: Array<{ runId: string }>
@@ -1350,7 +1422,7 @@ describe('doWorkflow(w6,fixture)', () => {
1350
1422
 
1351
1423
  const r = await handleSessionRead(
1352
1424
  { action: 'workflow', session: WF_ROOT, runId: 'wf-nonexist' },
1353
- dir,
1425
+ { agentDir: dir },
1354
1426
  )
1355
1427
  const d = r.details as { runs: unknown[]; runIds: string[]; requestedRunId?: string }
1356
1428
  expect(d.runs).toEqual([])
@@ -1369,7 +1441,7 @@ describe('doWorkflow(w6,fixture)', () => {
1369
1441
  await wfMainSession(dir, slug, WF_ROOT)
1370
1442
  // 无 wf-link(session 存在但未发起 workflow)
1371
1443
 
1372
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1444
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1373
1445
  const d = r.details as { runs: unknown[]; runIds: unknown[]; sessionId?: string }
1374
1446
  expect(d.runs).toEqual([])
1375
1447
  expect(d.runIds).toEqual([])
@@ -1391,7 +1463,7 @@ describe('doWorkflow(w6,fixture)', () => {
1391
1463
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-1', path: ghostPath })
1392
1464
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-2', path: wf2 })
1393
1465
 
1394
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1466
+ const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1395
1467
  const d = r.details as {
1396
1468
  runs: Array<{ runId: string }>
1397
1469
  runIds: string[]
@@ -1421,7 +1493,7 @@ describe('doWorkflow(w6,fixture)', () => {
1421
1493
  await wfLink(dir, slug, WF_ROOT, { runId: 'wf-jump-1', path: wfPath })
1422
1494
 
1423
1495
  // 第一次:workflow 概览,拿 call sessionId
1424
- const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
1496
+ const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1425
1497
  const dWf = rWf.details as {
1426
1498
  runs: Array<{ steps: Array<{ sessionId: string; sessionFile: string }> }>
1427
1499
  }
@@ -1432,7 +1504,7 @@ describe('doWorkflow(w6,fixture)', () => {
1432
1504
  // 第二次:用 call sessionId 调 outline(m0 resolveSessionId 三形态复用)
1433
1505
  const rOutline = await handleSessionRead(
1434
1506
  { action: 'outline', session: callSessionId },
1435
- dir,
1507
+ { agentDir: dir },
1436
1508
  )
1437
1509
  const dOutline = rOutline.details as { turns: unknown[] }
1438
1510
  expect(dOutline.turns.length).toBeGreaterThan(0)
@@ -1469,7 +1541,7 @@ describe('doWorkflow(w6,fixture)', () => {
1469
1541
  { flag: 'a' },
1470
1542
  )
1471
1543
 
1472
- const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, dir)
1544
+ const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, { agentDir: dir })
1473
1545
  const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
1474
1546
  expect(d.runs).toHaveLength(1)
1475
1547
  expect(d.runs[0].runId).toBe('wf-sub-1')
@@ -1490,7 +1562,7 @@ describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
1490
1562
  it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
1491
1563
  const r = await handleSessionRead(
1492
1564
  { action: 'workflow', session: REAL_WF_SESSION },
1493
- REAL,
1565
+ { agentDir: REAL },
1494
1566
  )
1495
1567
  const d = r.details as {
1496
1568
  runs: Array<{ runId: string; status: string; steps: unknown[] }>
@@ -1529,7 +1601,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1529
1601
  })
1530
1602
 
1531
1603
  it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
1532
- const r = await handleSessionRead({ action: 'family', session: MAIN }, dir)
1604
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1533
1605
  // details 是 Family 对象(root/subagents/workflows),非 { tree }
1534
1606
  const d = r.details as {
1535
1607
  root: { sessionId: string }
@@ -1547,7 +1619,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1547
1619
  it('TC-m3b-dofamily-recursive-true:recursive=true → ExecutionTree(details.tree)', async () => {
1548
1620
  const r = await handleSessionRead(
1549
1621
  { action: 'family', session: MAIN, recursive: true },
1550
- dir,
1622
+ { agentDir: dir },
1551
1623
  )
1552
1624
  const d = r.details as {
1553
1625
  tree: {
@@ -1573,6 +1645,142 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1573
1645
  })
1574
1646
  })
1575
1647
 
1648
+ // ===========================================================================
1649
+ // ext-simplify-04 U4/E2(D2②):formatFamilyText subagents 行富字段展示
1650
+ // status 终态短标签 + agent 名 + task 截 60 摘要;孤儿只标 [已清理];P4 输出量级守卫。
1651
+ // ===========================================================================
1652
+
1653
+ describe('formatFamilyText subagents 行富字段(ext-simplify-04 U4 / E2)', () => {
1654
+ const MAIN = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f1'
1655
+ const SUB = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f2'
1656
+ let dir: string
1657
+
1658
+ beforeEach(async () => {
1659
+ dir = await mkdtemp(join(tmpdir(), 'tool-handler-family-rich-'))
1660
+ await makeFixtureSession(dir, MAIN, 'main session')
1661
+ })
1662
+ afterEach(async () => {
1663
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1664
+ })
1665
+
1666
+ /** alive subagent:session 文件(header + 可选 identity 尾行)+ 可选 manifest 富字段 */
1667
+ async function writeSubagent(
1668
+ realId: string,
1669
+ opts: {
1670
+ manifest?: { agentName?: string; task?: string; slug?: string; model?: string; status?: string }
1671
+ identityTask?: string
1672
+ identityAgent?: string
1673
+ } = {},
1674
+ ): Promise<void> {
1675
+ const sessionDir = join(dir, 'subagents', '--demo-cwd--', 'sessions')
1676
+ await mkdir(sessionDir, { recursive: true })
1677
+ const subPath = join(sessionDir, `${realId}.jsonl`)
1678
+ const lines = [JSON.stringify({ type: 'session', id: realId, cwd: '/demo' })]
1679
+ if (opts.identityTask !== undefined || opts.identityAgent !== undefined) {
1680
+ lines.push(
1681
+ JSON.stringify({
1682
+ type: 'custom',
1683
+ customType: 'subagent-identity',
1684
+ data: {
1685
+ id: `sa-${realId.slice(0, 8)}`,
1686
+ rootSessionId: MAIN,
1687
+ slug: 'identity-slug',
1688
+ agent: opts.identityAgent ?? 'explorer',
1689
+ task: opts.identityTask ?? 't',
1690
+ },
1691
+ }),
1692
+ )
1693
+ }
1694
+ await writeFile(subPath, lines.join('\n') + '\n')
1695
+ if (opts.manifest) {
1696
+ const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
1697
+ await mkdir(recordsDir, { recursive: true })
1698
+ await writeFile(
1699
+ join(recordsDir, `sa-${realId}.json`),
1700
+ JSON.stringify({
1701
+ id: `sa-${realId}`,
1702
+ rootSessionId: MAIN,
1703
+ sessionFile: subPath,
1704
+ ...opts.manifest,
1705
+ }),
1706
+ )
1707
+ }
1708
+ }
1709
+
1710
+ /** 孤儿 manifest:sessionFile 指向不存在路径(模拟 .jsonl 被 GC) */
1711
+ async function writeOrphanManifest(
1712
+ saId: string,
1713
+ fields: { agentName?: string; task?: string; slug?: string; status?: string } = {},
1714
+ ): Promise<void> {
1715
+ const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
1716
+ await mkdir(recordsDir, { recursive: true })
1717
+ await writeFile(
1718
+ join(recordsDir, `${saId}.json`),
1719
+ JSON.stringify({ id: saId, rootSessionId: MAIN, sessionFile: '/nonexistent/gc.jsonl', ...fields }),
1720
+ )
1721
+ }
1722
+
1723
+ it('manifest 主路径:subagents 行含 [status] agent 名 · task 摘要;root 段不变', async () => {
1724
+ await writeSubagent(SUB, {
1725
+ manifest: { agentName: 'coder', task: '修复登录表单校验并补测试', slug: 'fix-login', status: 'completed' },
1726
+ })
1727
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1728
+ const text = r.content[0].text
1729
+ // 富字段行形态(设计 §3.1):slug 后接 [status] agent · task 摘要
1730
+ expect(text).toContain('slug=fix-login [completed] coder · 修复登录表单校验并补测试')
1731
+ // root/parents/forks/workflows 段不变(root 行含完整 id + 日期括号)
1732
+ expect(text).toContain(`root: ${MAIN} (`)
1733
+ })
1734
+
1735
+ it('P4 输出量级:task 截 60 字符(超出加省略号),换行压平不破坏行结构', async () => {
1736
+ const SUB2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f3'
1737
+ await writeSubagent(SUB, {
1738
+ manifest: { agentName: 'coder', task: 'A'.repeat(100), slug: 'long-task', status: 'running' },
1739
+ })
1740
+ await writeSubagent(SUB2, {
1741
+ manifest: { agentName: 'worker', task: 'line1\nline2\ttabbed', slug: 'nl-task', status: 'failed' },
1742
+ })
1743
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1744
+ const text = r.content[0].text
1745
+ // 截断:恰 60 字符 + 省略号,原文 61+ 连续段不出现
1746
+ expect(text).toContain(`${'A'.repeat(60)}…`)
1747
+ expect(text).not.toContain('A'.repeat(61))
1748
+ // 换行/制表压平为单空格
1749
+ expect(text).toContain('slug=nl-task [failed] worker · line1 line2 tabbed')
1750
+ // 行结构:subagents 段 = 头行 + 每 subagent 恰一行(换行未折叠出额外行)
1751
+ const lines = text.split('\n')
1752
+ const headIdx = lines.indexOf('subagents:')
1753
+ const subLines = lines.slice(headIdx + 1)
1754
+ expect(subLines).toHaveLength(2)
1755
+ expect(subLines.every((l) => l.startsWith(' ') && l.trim().length > 0)).toBe(true)
1756
+ })
1757
+
1758
+ it('孤儿(cleanedUp):仍标 [已清理],不展开 status/agent/task 摘要', async () => {
1759
+ await writeOrphanManifest('sa-ghost-id', {
1760
+ agentName: 'worker',
1761
+ task: 'ghost task',
1762
+ slug: 'ghost-slug',
1763
+ status: 'completed',
1764
+ })
1765
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1766
+ const text = r.content[0].text
1767
+ expect(text).toContain('slug=ghost-slug [已清理]')
1768
+ // 已清理即终局:manifest 富字段不进文本(无深读入口,摘要无消费价值)
1769
+ expect(text).not.toContain('ghost task')
1770
+ expect(text).not.toContain('[completed]')
1771
+ })
1772
+
1773
+ it('P-fallback(无 manifest):identity 回退 task/agent 仍展示,status 无则无标签', async () => {
1774
+ await writeSubagent(SUB, { identityTask: 'fix bug', identityAgent: 'worker' })
1775
+ const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1776
+ const text = r.content[0].text
1777
+ // P-fallback:task/agent 从 identity 回退(slug 取 identity.slug),status 不可回退 → 无 [x] 标签
1778
+ expect(text).toContain('slug=identity-slug worker · fix bug')
1779
+ expect(text).not.toContain('[completed]')
1780
+ expect(text).not.toContain('[running]')
1781
+ })
1782
+ })
1783
+
1576
1784
  // ===========================================================================
1577
1785
  // doctor action(u8:design 2026-09-10 §6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)
1578
1786
  // 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
@@ -1581,7 +1789,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1581
1789
  // doctor details 契约直用 doctor.ts 导出的 DoctorDetails(不再自建局部镜像——生产侧
1582
1790
  // 形状漂移由编译期捕捉,S-R2 同步)
1583
1791
 
1584
- describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob + 缓存)', () => {
1792
+ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob)', () => {
1585
1793
  let tmp: string
1586
1794
 
1587
1795
  const SLUG = '--Users-foo--'
@@ -1753,59 +1961,31 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
1753
1961
  expect(fullSub?.exists).toBe(true)
1754
1962
  })
1755
1963
 
1756
- it('进程内缓存:同根二次调用命中缓存不重扫;根目录 mtime 变化即失效重扫(§7B 要点 8)', async () => {
1964
+ it('无缓存形态(ext-simplify-04 U3):重复调用输出一致(每次实扫),新落盘文件立即可见', async () => {
1965
+ // 原三个用例(缓存命中不重扫 / TTL 到期失效 / find 不读 doctor 缓存 PS-14)随
1966
+ // doctor 缓存机删除而合并为本用例——无缓存层后「find/doctor 读到旧计数」的形态
1967
+ // 在结构上不存在,守卫点收敛为:输出无「缓存命中」行 + 每次调用实扫(新增文件
1968
+ // 下一次调用即见,反而更实时)
1757
1969
  const agentDir = join(tmp, 'agent')
1758
1970
  await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
1759
1971
  const signals: SessionReadSignals = { agentDir }
1760
1972
 
1761
1973
  const first = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
1762
1974
  const firstDef = first.roots.find((x) => x.kind === 'default')!
1763
- expect(firstDef.cached).toBeUndefined() // 首扫
1764
1975
  expect(firstDef.fileCount).toBe(1)
1765
1976
 
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)
1977
+ const second = await handleSessionRead({ action: 'doctor' }, signals)
1978
+ const secondDef = (second.details as DoctorDetails).roots.find((x) => x.kind === 'default')!
1979
+ expect(secondDef.fileCount).toBe(1) // 两次输出一致(同目录无变化)
1980
+ expect(second.content[0].text).not.toContain('缓存命中')
1770
1981
 
1771
- // 根目录 mtime 变化(根层新增文件)→ 失效重扫,计数更新
1772
- await writeJsonl(join(agentDir, 'sessions', 'b.jsonl'), 'bbbbbbbb')
1982
+ // 新 session 落盘 → 下一次 doctor / find 均立即可见(无缓存层,每次实扫)
1983
+ await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
1773
1984
  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)
1985
+ expect(third.roots.find((x) => x.kind === 'default')!.fileCount).toBe(2)
1986
+ const r = await handleSessionRead({ action: 'find', query: 'bbbbbbbb' }, { agentDir })
1807
1987
  const d = r.details as { matches: Array<{ sessionId: string }> }
1808
- expect(d.matches.some((m) => m.sessionId.includes('id-find-later-xyz'))).toBe(true)
1988
+ expect(d.matches.some((m) => m.sessionId.includes('bbbbbbbb'))).toBe(true)
1809
1989
  })
1810
1990
 
1811
1991
  it('env/bundleUrl 信号缺失降级:仍出环境判定行(standalone-pi · 未知)+ 完整根表,不抛错', async () => {
@@ -1871,7 +2051,7 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
1871
2051
 
1872
2052
  /** find action 便捷调用(agentDir = tmp/agent)。 */
1873
2053
  function find(query: string, extra?: Partial<SessionReadParams>): Promise<ToolResultLike> {
1874
- return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
2054
+ return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
1875
2055
  }
1876
2056
 
1877
2057
  interface ToolResultLike {
@@ -1985,9 +2165,10 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
1985
2165
  expect((hexJunk.details as { matches: unknown[] }).matches).toHaveLength(0)
1986
2166
  })
1987
2167
 
1988
- it('⑥ 自检行计数 = 本次实扫结果(不读 doctor 缓存)+ 去重根注记', async () => {
2168
+ it('⑥ 自检行计数 = 本次实扫结果 + 去重根注记', async () => {
1989
2169
  const agentDir = join(tmp, 'agent')
1990
- // PS-14 形态:doctor 首跑时 main 根空 → 缓存 fileCount=0
2170
+ // 根扫描无缓存(doctor 缓存机已删,ext-simplify-04 U3)——doctor 首跑后落盘的
2171
+ // 文件,find 自检行照实扫计数(原 PS-14「doctor 缓存 0 文件」形态结构性不存在)
1991
2172
  await mkdir(join(agentDir, 'sessions'), { recursive: true })
1992
2173
  await handleSessionRead({ action: 'doctor' }, { agentDir })
1993
2174
  // 之后 session 落盘(main 2 + subagent 1)
@@ -2000,7 +2181,6 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
2000
2181
  { agentDir },
2001
2182
  )
2002
2183
  const text = r.content[0].text
2003
- // 若 F1 读 doctor 缓存(0 文件),以下计数断言必红
2004
2184
  expect(text).toContain(`${join(agentDir, 'sessions')}:2 文件`)
2005
2185
  expect(text).toContain(`${join(agentDir, 'subagents')}:1 文件`)
2006
2186
  expect(text).toContain('候选集非空(共 3 文件)')
@@ -2072,7 +2252,7 @@ describe('片段解析接通 [live] 根(fixture,同一 roots 契约)', ()
2072
2252
  // 负向对照:不传 liveSessionDir(三根降级)→ 同一片段 F1 零匹配
2073
2253
  //([default]/[legacy]/[subagent] 均无此文件——证明命中确实来自 [live] 根)
2074
2254
  await expect(
2075
- handleSessionRead({ action: 'outline', session: FRAGMENT }, agentDir),
2255
+ handleSessionRead({ action: 'outline', session: FRAGMENT }, { agentDir }),
2076
2256
  ).rejects.toThrow(/无匹配 session/)
2077
2257
  })
2078
2258
 
@@ -2176,7 +2356,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2176
2356
  query: string,
2177
2357
  extra?: Partial<SessionReadParams>,
2178
2358
  ): ReturnType<typeof handleSessionRead> {
2179
- return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
2359
+ return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
2180
2360
  }
2181
2361
 
2182
2362
  interface FindDetails {
@@ -2304,7 +2484,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2304
2484
 
2305
2485
  const res = await handleSessionRead(
2306
2486
  { action: 'result', session: 'sa-u10-r1,sa-u10-r2' },
2307
- join(tmp, 'agent'),
2487
+ { agentDir: join(tmp, 'agent') },
2308
2488
  )
2309
2489
  const text = res.content[0].text
2310
2490
  expect(text).toContain(`(session ${R1.slice(0, 8)}…)`)
@@ -2331,7 +2511,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2331
2511
 
2332
2512
  // 解析路径(resolveByFragment → findSessions limit:10 无 source)保持 mtime 排序 +
2333
2513
  // limit 截断原语义:5 命中全进 F2 消歧候选,无 main 置顶/配额分组介入(§6.7 末条)
2334
- const r = await handleSessionRead({ action: 'outline', session: PREFIX }, join(tmp, 'agent'))
2514
+ const r = await handleSessionRead({ action: 'outline', session: PREFIX }, { agentDir: join(tmp, 'agent') })
2335
2515
  const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
2336
2516
  expect(d.ambiguous).toBe(true)
2337
2517
  expect(d.candidates).toHaveLength(5)
@@ -2355,9 +2535,107 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
2355
2535
  })
2356
2536
  })
2357
2537
 
2538
+ // ============================================================
2539
+ // E1:find 单次根解析(ext-simplify-04 U2,探针 P1)。spy 打在 roots 模块命名空间
2540
+ // 上跨模块拦截(find.ts / findNoMatch 渲染链的消费点全走同一入口),断言 doFind
2541
+ // 全路径(分组 / 显式 source / 零匹配)resolveSessionRoots 恰调用 1 次——改前分组
2542
+ // 2 次、零匹配 3 次(impl-plan D-15④ 登记的分组双倍实扫债),预解析根复用后恒 1 次。
2543
+ // ============================================================
2544
+ describe('E1 find 单次根解析(探针 P1:resolveSessionRoots 恰 1 次)', () => {
2545
+ let tmp: string
2546
+ const SLUG = '--e1-cwd--'
2547
+ const PREFIX = '019e7e10'
2548
+ const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
2549
+ const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
2550
+
2551
+ /** 写 session 文件(subagent:true 写入 subagent 根),形态同 u10 段 writeSession。 */
2552
+ async function writeSession(id: string, opts?: { subagent?: boolean }): Promise<void> {
2553
+ const dir = opts?.subagent
2554
+ ? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
2555
+ : join(tmp, 'agent', 'sessions', SLUG)
2556
+ await mkdir(dir, { recursive: true })
2557
+ await writeFile(
2558
+ join(dir, `${id}.jsonl`),
2559
+ JSON.stringify({ type: 'session', id, cwd: '/e1' }) + '\n',
2560
+ )
2561
+ }
2562
+
2563
+ function find(
2564
+ query: string,
2565
+ extra?: Partial<SessionReadParams>,
2566
+ ): ReturnType<typeof handleSessionRead> {
2567
+ return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
2568
+ }
2569
+
2570
+ /** spy 计数器 + 断言 helper:包装一段 find 调用,返回 { 结果, 调用次数 }。 */
2571
+ async function findCounting(
2572
+ query: string,
2573
+ extra?: Partial<SessionReadParams>,
2574
+ ): Promise<{ result: Awaited<ReturnType<typeof handleSessionRead>>; resolveCalls: number }> {
2575
+ const rootsModule = await import('../discovery/roots.js')
2576
+ const spy = vi.spyOn(rootsModule, 'resolveSessionRoots')
2577
+ try {
2578
+ const result = await find(query, extra)
2579
+ return { result, resolveCalls: spy.mock.calls.length }
2580
+ } finally {
2581
+ spy.mockRestore()
2582
+ }
2583
+ }
2584
+
2585
+ beforeEach(async () => {
2586
+ tmp = await mkdtemp(join(tmpdir(), 'tool-handler-e1-'))
2587
+ })
2588
+ afterEach(async () => {
2589
+ await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2590
+ })
2591
+
2592
+ it('① 分组路径(main + subagent 两段查询,有命中):恰 1 次', async () => {
2593
+ await writeSession(mainId(1))
2594
+ await writeSession(subId(1), { subagent: true })
2595
+ const { result, resolveCalls } = await findCounting(PREFIX)
2596
+ const d = result.details as { matches: Array<{ source: string }>; truncated: boolean }
2597
+ // 前置:两段确实各自命中(分组语义不变),再断言扫描收敛
2598
+ expect(d.matches).toHaveLength(2)
2599
+ expect(new Set(d.matches.map((m) => m.source))).toEqual(new Set(['main', 'subagent']))
2600
+ expect(d.truncated).toBe(false)
2601
+ expect(resolveCalls).toBe(1)
2602
+ })
2603
+
2604
+ it('② 显式 source 路径(单组查询,有命中):恰 1 次', async () => {
2605
+ await writeSession(mainId(1))
2606
+ await writeSession(subId(1), { subagent: true })
2607
+ const { result, resolveCalls } = await findCounting(PREFIX, { source: 'subagent' })
2608
+ const d = result.details as { matches: Array<{ source: string }> }
2609
+ expect(d.matches).toHaveLength(1)
2610
+ expect(d.matches[0].source).toBe('subagent')
2611
+ expect(resolveCalls).toBe(1)
2612
+ })
2613
+
2614
+ it('③ 分组零匹配 → F1 自检行:恰 1 次(改前 3 次)且根表仍进渲染', async () => {
2615
+ await writeSession(mainId(1))
2616
+ const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x')
2617
+ const d = result.details as { matches: unknown[]; truncated: boolean }
2618
+ expect(d.matches).toEqual([])
2619
+ expect(d.truncated).toBe(false)
2620
+ // F1 自检行消费预解析根表(非空根计数可见 = roots 确实传入而非空列表)
2621
+ expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
2622
+ expect(result.content[0].text).toContain('候选集非空')
2623
+ expect(resolveCalls).toBe(1)
2624
+ })
2625
+
2626
+ it('④ 显式 source 零匹配 → F1 自检行:恰 1 次(改前 2 次)', async () => {
2627
+ await writeSession(subId(1), { subagent: true })
2628
+ const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x', { source: 'main' })
2629
+ expect(result.content[0].text).toContain('无匹配 session')
2630
+ expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
2631
+ expect(resolveCalls).toBe(1)
2632
+ })
2633
+ })
2634
+
2358
2635
  // ============================================================
2359
2636
  // u11:metadataProvider 注入 + TTL 缓存(design 2026-09-10 §6.6)
2360
- // handler 级集成:注入包装(withMetadataCache 独立实例,不与 doctorScanCache 串扰)
2637
+ // handler 级集成:注入包装(metadata 缓存独立实例——根扫描无缓存,doctor 缓存机
2638
+ // 已删除,ext-simplify-04 U3,不存在串扰面)
2361
2639
  // + 标题渲染(§5.1 候选行标题优先)+ provider 抛错降级不外抛。
2362
2640
  // 策略①惰性/②窄化/合并语义在 find.test.ts u11 段覆盖。
2363
2641
  // ============================================================
@@ -2567,7 +2845,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2567
2845
  const ids = Array.from({ length: MULTI_SEARCH_MAX_SESSIONS + 1 }, (_, i) => mainId(i + 10))
2568
2846
  const r = await handleSessionRead(
2569
2847
  { action: 'search', session: ids.join(','), pattern: 'adj_factor' },
2570
- join(tmp, 'agent'),
2848
+ { agentDir: join(tmp, 'agent') },
2571
2849
  )
2572
2850
  const text = r.content[0].text
2573
2851
  expect(text).toContain('跨会话检索已拒绝')
@@ -2697,7 +2975,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2697
2975
  await writeSession(A, [{ role: 'user', text: '普通内容' }])
2698
2976
  const r = await handleSessionRead(
2699
2977
  { action: 'search', session: `${A},${GHOST}`, pattern: 'zzznothing' },
2700
- join(tmp, 'agent'),
2978
+ { agentDir: join(tmp, 'agent') },
2701
2979
  )
2702
2980
  const text = r.content[0].text
2703
2981
  expect(text).toContain('0/1 session(s) hit for /zzznothing/')
@@ -2721,7 +2999,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2721
2999
  // 单 id:原 details.hits 结构与 header 形态不变
2722
3000
  const r = await handleSessionRead(
2723
3001
  { action: 'search', session: A, pattern: 'plugin' },
2724
- join(tmp, 'agent'),
3002
+ { agentDir: join(tmp, 'agent') },
2725
3003
  )
2726
3004
  expect(r.content[0].text).toContain('1 hit(s) for /plugin/')
2727
3005
  const d = r.details as { hits: unknown[]; truncated: boolean; scanned?: unknown }
@@ -2731,7 +3009,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
2731
3009
  // pattern 含逗号不触发跨会话(分流只看 session)
2732
3010
  const r2 = await handleSessionRead(
2733
3011
  { action: 'search', session: A, pattern: 'aaa,bbb' },
2734
- join(tmp, 'agent'),
3012
+ { agentDir: join(tmp, 'agent') },
2735
3013
  )
2736
3014
  expect((r2.details as { hits: unknown[] }).hits).toHaveLength(0)
2737
3015
  expect((r2.details as { scanned?: unknown }).scanned).toBeUndefined()