@zhushanwen/pi-session-reader 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -18,7 +18,7 @@
18
18
  "devDependencies": {
19
19
  "@vitest/coverage-v8": "^4.1.9",
20
20
  "vitest": "^4.1.8",
21
- "@zhushanwen/subagent-core": "0.6.0"
21
+ "@zhushanwen/subagent-core": "0.7.1"
22
22
  },
23
23
  "files": [
24
24
  "index.ts",
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ detectEnvironment,
4
+ XYZ_AGENT_EXT_LOG_ENV,
5
+ PI_CODING_AGENT_DIR_ENV,
6
+ XYZ_AGENT_DATA_DIR_ENV,
7
+ type EnvironmentSignals,
8
+ } from '../discovery/env.js'
9
+
10
+ /**
11
+ * U2 环境判定单测(design §6.2 / §4.4)。detectEnvironment 是纯函数:
12
+ * 全部信号为构造的字面量字符串,无任何 fs / 真实数据目录触碰。
13
+ */
14
+
15
+ /** 构造信号包的基线:agentDir 用假路径,不指向任何真实目录 */
16
+ function signals(overrides: Partial<EnvironmentSignals> = {}): EnvironmentSignals {
17
+ return {
18
+ env: {},
19
+ agentDir: '/tmp/nonexistent-fixture/.pi/agent',
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ describe('detectEnvironment 托管合取判定', () => {
25
+ it('双信号齐备 + B 后新布局 <dataDir>/agent → xyz-agent(packaged)', () => {
26
+ const r = detectEnvironment(
27
+ signals({
28
+ env: {
29
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
30
+ [PI_CODING_AGENT_DIR_ENV]: '/Users/u/.xyz-agent/agent',
31
+ },
32
+ bundleUrl:
33
+ 'file:///Applications/TaiJi.app/Contents/Resources/extensions/@zhushanwen/pi-session-reader/index.mjs',
34
+ }),
35
+ )
36
+ expect(r.kind).toBe('xyz-agent')
37
+ expect(r.distribution).toBe('packaged')
38
+ expect(r.dataDir).toBe('/Users/u/.xyz-agent')
39
+ })
40
+
41
+ it('双信号齐备 + B 前旧布局 <dataDir>/pi/agent → xyz-agent(迁移窗口期双形态兼容)', () => {
42
+ const r = detectEnvironment(
43
+ signals({
44
+ env: {
45
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
46
+ [PI_CODING_AGENT_DIR_ENV]: '/Users/u/.xyz-agent-dev/pi/agent',
47
+ },
48
+ bundleUrl: 'file:///Users/u/Code/xyz-agent/extensions/universal/session-reader/src/index.ts',
49
+ }),
50
+ )
51
+ expect(r.kind).toBe('xyz-agent')
52
+ expect(r.distribution).toBe('dev')
53
+ expect(r.dataDir).toBe('/Users/u/.xyz-agent-dev')
54
+ })
55
+
56
+ it('透传污染反例 1:仅 XYZ_AGENT_EXT_LOG=1(PI_CODING_AGENT_DIR 未设)→ standalone-pi,evidence 点名孤置信号', () => {
57
+ const r = detectEnvironment(
58
+ signals({ env: { [XYZ_AGENT_EXT_LOG_ENV]: '1' } }),
59
+ )
60
+ expect(r.kind).toBe('standalone-pi')
61
+ expect(r.dataDir).toBeUndefined()
62
+ const joined = r.evidence.join('\n')
63
+ expect(joined).toContain(`${XYZ_AGENT_EXT_LOG_ENV}='1' → 命中`)
64
+ expect(joined).toContain('单独成立不合取')
65
+ expect(joined).toContain(XYZ_AGENT_EXT_LOG_ENV)
66
+ })
67
+
68
+ it('透传污染反例 2:仅 XYZ_AGENT_EXT_LOG=1 且 PI_CODING_AGENT_DIR 为裸 pi 形态 → standalone-pi', () => {
69
+ const r = detectEnvironment(
70
+ signals({
71
+ env: {
72
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
73
+ [PI_CODING_AGENT_DIR_ENV]: '/home/u/.pi/agent',
74
+ },
75
+ }),
76
+ )
77
+ expect(r.kind).toBe('standalone-pi')
78
+ expect(r.evidence.join('\n')).toContain('形态不匹配')
79
+ })
80
+
81
+ it('透传污染反例 3:仅 PI_CODING_AGENT_DIR 形态匹配(EXT_LOG 未设)→ standalone-pi,evidence 点名孤置信号', () => {
82
+ const r = detectEnvironment(
83
+ signals({
84
+ env: { [PI_CODING_AGENT_DIR_ENV]: '/Users/u/.xyz-agent/agent' },
85
+ }),
86
+ )
87
+ expect(r.kind).toBe('standalone-pi')
88
+ expect(r.dataDir).toBeUndefined()
89
+ const joined = r.evidence.join('\n')
90
+ expect(joined).toContain('形态匹配')
91
+ expect(joined).toContain('单独成立不合取')
92
+ expect(joined).toContain(PI_CODING_AGENT_DIR_ENV)
93
+ })
94
+
95
+ it('XYZ_AGENT_EXT_LOG 非 1 值(如 0 / true)不构成命中', () => {
96
+ for (const value of ['0', 'true', '']) {
97
+ const r = detectEnvironment(
98
+ signals({
99
+ env: {
100
+ [XYZ_AGENT_EXT_LOG_ENV]: value,
101
+ [PI_CODING_AGENT_DIR_ENV]: '/Users/u/.xyz-agent/agent',
102
+ },
103
+ }),
104
+ )
105
+ expect(r.kind, `EXT_LOG='${value}' 不应判托管`).toBe('standalone-pi')
106
+ }
107
+ })
108
+
109
+ it('形态防误报:无点前缀 xyz-agent、嵌套浅段不匹配', () => {
110
+ for (const dir of ['/home/u/xyz-agent/agent', '/home/u/.xyz/agent', '/.pi/agent']) {
111
+ const r = detectEnvironment(
112
+ signals({
113
+ env: {
114
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
115
+ [PI_CODING_AGENT_DIR_ENV]: dir,
116
+ },
117
+ }),
118
+ )
119
+ expect(r.kind, `${dir} 不应判托管`).toBe('standalone-pi')
120
+ }
121
+ })
122
+ })
123
+
124
+ describe('detectEnvironment distribution(bundle 路径三态)', () => {
125
+ it('.app/Contents/Resources/extensions/ → packaged', () => {
126
+ const r = detectEnvironment(
127
+ signals({
128
+ bundleUrl:
129
+ 'file:///Applications/TaiJi.app/Contents/Resources/extensions/@zhushanwen/pi-session-reader/index.mjs',
130
+ }),
131
+ )
132
+ expect(r.distribution).toBe('packaged')
133
+ })
134
+
135
+ it('Windows/Linux 安装资源目录 resources/extensions/ → packaged', () => {
136
+ const r = detectEnvironment(
137
+ signals({
138
+ bundleUrl:
139
+ 'file:///C:/Users/u/AppData/Local/Programs/taiji/resources/extensions/@zhushanwen/pi-session-reader/index.mjs',
140
+ }),
141
+ )
142
+ expect(r.distribution).toBe('packaged')
143
+ })
144
+
145
+ it('仓库路径(源码与 dev staged 资源根两种)→ dev,staged 根不被误判 packaged', () => {
146
+ for (const url of [
147
+ 'file:///Users/u/Code/xyz-agent/extensions/universal/session-reader/src/index.ts',
148
+ 'file:///Users/u/Code/xyz-agent/apps/electron/resources/extensions/@zhushanwen/pi-session-reader/index.mjs',
149
+ ]) {
150
+ const r = detectEnvironment(signals({ bundleUrl: url }))
151
+ expect(r.distribution, `${url} 应判 dev`).toBe('dev')
152
+ }
153
+ })
154
+
155
+ it('bundleUrl 缺失 → null(不猜)', () => {
156
+ const r = detectEnvironment(signals({}))
157
+ expect(r.distribution).toBeNull()
158
+ expect(r.evidence.join('\n')).toContain('无法判定')
159
+ })
160
+ })
161
+
162
+ describe('detectEnvironment dataDir 推导', () => {
163
+ it('托管:XYZ_AGENT_DATA_DIR 优先于 PI_CODING_AGENT_DIR 剥层', () => {
164
+ const r = detectEnvironment(
165
+ signals({
166
+ env: {
167
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
168
+ [PI_CODING_AGENT_DIR_ENV]: '/data/.xyz-agent/pi/agent',
169
+ [XYZ_AGENT_DATA_DIR_ENV]: '/data/.xyz-agent',
170
+ },
171
+ }),
172
+ )
173
+ expect(r.kind).toBe('xyz-agent')
174
+ expect(r.dataDir).toBe('/data/.xyz-agent')
175
+ expect(r.evidence.join('\n')).toContain(`来源:${XYZ_AGENT_DATA_DIR_ENV}`)
176
+ })
177
+
178
+ it('托管:无 DATA_DIR 时按布局剥层——新布局剥一层、旧布局剥两层', () => {
179
+ const newLayout = detectEnvironment(
180
+ signals({
181
+ env: {
182
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
183
+ [PI_CODING_AGENT_DIR_ENV]: '/data/.xyz-agent/agent',
184
+ },
185
+ }),
186
+ )
187
+ expect(newLayout.dataDir).toBe('/data/.xyz-agent')
188
+
189
+ const oldLayout = detectEnvironment(
190
+ signals({
191
+ env: {
192
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
193
+ [PI_CODING_AGENT_DIR_ENV]: '/data/.xyz-agent/pi/agent',
194
+ },
195
+ }),
196
+ )
197
+ expect(oldLayout.dataDir).toBe('/data/.xyz-agent')
198
+ })
199
+
200
+ it('standalone:即使 XYZ_AGENT_DATA_DIR 被设置也恒 undefined', () => {
201
+ const r = detectEnvironment(
202
+ signals({
203
+ env: {
204
+ [XYZ_AGENT_DATA_DIR_ENV]: '/data/.xyz-agent',
205
+ },
206
+ }),
207
+ )
208
+ expect(r.kind).toBe('standalone-pi')
209
+ expect(r.dataDir).toBeUndefined()
210
+ })
211
+ })
212
+
213
+ describe('detectEnvironment evidence', () => {
214
+ const scenarios: Array<[string, EnvironmentSignals]> = [
215
+ ['全信号缺失', signals({})],
216
+ ['托管全信号', signals({
217
+ env: {
218
+ [XYZ_AGENT_EXT_LOG_ENV]: '1',
219
+ [PI_CODING_AGENT_DIR_ENV]: '/data/.xyz-agent/agent',
220
+ [XYZ_AGENT_DATA_DIR_ENV]: '/data/.xyz-agent',
221
+ },
222
+ bundleUrl:
223
+ 'file:///Applications/TaiJi.app/Contents/Resources/extensions/@zhushanwen/pi-session-reader/index.mjs',
224
+ })],
225
+ ['EXT_LOG 孤置', signals({ env: { [XYZ_AGENT_EXT_LOG_ENV]: '1' } })],
226
+ ['agentDir 孤置', signals({ env: { [PI_CODING_AGENT_DIR_ENV]: '/data/.xyz-agent/agent' } })],
227
+ ['bundleUrl 缺失', signals({})],
228
+ ]
229
+
230
+ it('各场景 evidence 恒非空', () => {
231
+ for (const [name, s] of scenarios) {
232
+ const r = detectEnvironment(s)
233
+ expect(r.evidence.length, `${name} 场景 evidence 不应为空`).toBeGreaterThan(0)
234
+ }
235
+ })
236
+
237
+ it('每条信号的原文(命中与否)都在 evidence 中', () => {
238
+ const r = detectEnvironment(
239
+ signals({
240
+ env: {
241
+ [XYZ_AGENT_EXT_LOG_ENV]: '0',
242
+ [PI_CODING_AGENT_DIR_ENV]: '/data/.xyz-agent/pi/agent',
243
+ },
244
+ agentDir: '/data/.xyz-agent/pi/agent',
245
+ }),
246
+ )
247
+ const joined = r.evidence.join('\n')
248
+ // 未命中信号:原文 + 未命中注记
249
+ expect(joined).toContain(`${XYZ_AGENT_EXT_LOG_ENV}='0' → 未命中`)
250
+ // 命中信号:原文 + 匹配注记
251
+ expect(joined).toContain(`${PI_CODING_AGENT_DIR_ENV}='/data/.xyz-agent/pi/agent'`)
252
+ expect(joined).toContain('形态匹配')
253
+ // 第三条输入:agentDir 透传记录
254
+ expect(joined).toContain(`agentDir='/data/.xyz-agent/pi/agent'`)
255
+ // 判定结论行
256
+ expect(joined).toContain('standalone-pi')
257
+ })
258
+ })
@@ -18,7 +18,7 @@ import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } f
18
18
  * - TC-m3b-cycle-detection:workflow 指针环(A→B→A),visited Set 防环
19
19
  * - TC-m3b-single-node:单节点树(无后代,ES5)
20
20
  * - TC-m3b-source-priority:parentRecordId 三级数据源优先级(manifest>identity>flat,DM4)
21
- * - TC-m3b-real-data-guard:本机真实数据 flat 回退(旧机制,skipIf CI 无数据)
21
+ * - TC-m3b-real-data-guard:本机真实语料解析不抛错、root 定位正确(skipIf CI 无数据)
22
22
  */
23
23
 
24
24
  // ---- fixture 常量(uuid 特征,互不为子串,满足 extractSessionIdFromFilename)----
@@ -692,17 +692,18 @@ describe('buildExecutionTree - fixture', () => {
692
692
  // ============================================================
693
693
 
694
694
  describe.skipIf(!HAS_REAL)('buildExecutionTree - 真实数据守卫', () => {
695
- it('TC-m3b-real-data-guard:本机旧机制数据 flat 回退,不抛错,totalNodes>1', async () => {
695
+ it('TC-m3b-real-data-guard:本机真实语料解析不抛错,root 定位正确', async () => {
696
696
  // 取一个真实存在的 main session(FAM 是 fork 家族根)
697
697
  const FAM = '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7'
698
698
  const tree = await buildExecutionTree(FAM, REAL_AGENT_DIR)
699
- // 旧机制:sourceMode='flat-fallback'(全无 parentRecordId)
700
- expect(tree.sourceMode).toBe('flat-fallback')
701
- // 有 subagent 后代(本机 3610 record,FAM 树非空)
702
- expect(tree.totalNodes).toBeGreaterThan(1)
703
- // 不抛错(已隐含:到这行说明成功)
699
+ // [HISTORICAL] 只断言与宿主数据纪元无关的不变量:sourceMode / totalNodes 取决于 FAM
700
+ // 家族在 subagents/**/records 下的 manifest 存量,会被 pi GC 清空(2026-09-11 实测该
701
+ // 家族 records/ 已空 → related=[] → sourceMode='precise'、totalNodes=1,原 flat-fallback
702
+ // + totalNodes>1 断言随数据演化假红)。flat-fallback 语义由 fixture 用例
703
+ // TC-m3b-flat-fallback 确定性覆盖,不依赖本机可变语料。
704
704
  expect(tree.root.type).toBe('main')
705
705
  expect(tree.root.sessionId).toBe(FAM)
706
+ expect(tree.totalNodes).toBeGreaterThanOrEqual(1)
706
707
  }, 60000)
707
708
  })
708
709
 
@@ -1,9 +1,9 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { findSessions } from '../discovery/find.js'
6
- import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR } from './real-data.js'
6
+ import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
7
7
 
8
8
  /**
9
9
  * 建一个假 session 文件:首行 header(type=session,含 id/cwd/parentSession),
@@ -523,7 +523,7 @@ describe('findSessions', () => {
523
523
  expect(matches.every((m) => m.source === 'subagent')).toBe(true)
524
524
  expect(typeof truncated).toBe('boolean')
525
525
  },
526
- 30000,
526
+ REAL_DATA_TIMEOUT_MS,
527
527
  )
528
528
  })
529
529
 
@@ -536,7 +536,7 @@ describe('findSessions', () => {
536
536
  expect(hit.fileName).toContain('019e6c96')
537
537
  expect(hit.mtime).toBeGreaterThan(0)
538
538
  expect(hit.sizeBytes).toBeGreaterThan(0)
539
- }, 30000)
539
+ }, REAL_DATA_TIMEOUT_MS)
540
540
 
541
541
  it.skipIf(!HAS_E6)("真实数据:recent 返回最近 N 个,mtime 倒序,truncated=true", async () => {
542
542
  const { matches, truncated } = await findSessions('recent', REAL_AGENT_DIR, { limit: 5 })
@@ -548,7 +548,7 @@ describe('findSessions', () => {
548
548
  }
549
549
  // 真实 session 文件远多于 5 → 截断
550
550
  expect(truncated).toBe(true)
551
- }, 30000)
551
+ }, REAL_DATA_TIMEOUT_MS)
552
552
 
553
553
  it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
554
554
  "真实数据:source:'subagent' 能找到 completed subagent(§7 场景 1 find 部分)",
@@ -564,6 +564,211 @@ describe('findSessions', () => {
564
564
  expect(m.fileName).toContain('subagents')
565
565
  }
566
566
  },
567
- 30000,
567
+ REAL_DATA_TIMEOUT_MS,
568
568
  )
569
569
  })
570
+
571
+ // ============================================================
572
+ // u11:标题元数据 metadataProvider 注入(design 2026-09-10 §6.6)
573
+ // 覆盖三条调用策略中的 ①惰性 ②窄化(TTL 缓存句柄在 tool-handler 注入侧,
574
+ // 见 tool-handler.test.ts u11 段)+ 两条 guard + §11.3b 多目录合并语义
575
+ // + 纯 TS 首条 user 降级路径保留。
576
+ // provider 一律注入替身(不真调 pi SessionManager)。
577
+ // ============================================================
578
+ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
579
+ let root: string
580
+ let agentDir: string
581
+ /** 平铺 main 根(xyz-agent 形态:session 直在 sessions/ 下,[default] 根无子目录) */
582
+ let flatMain: string
583
+ /** legacy 根(dirname(agentDir)/sessions) */
584
+ let legacyDir: string
585
+
586
+ async function writeSession(
587
+ dir: string,
588
+ opts: { name: string; id: string; cwd?: string; firstUserText?: string },
589
+ ): Promise<string> {
590
+ await mkdir(dir, { recursive: true })
591
+ const lines = [JSON.stringify({ type: 'session', id: opts.id, cwd: opts.cwd ?? '/demo' })]
592
+ if (opts.firstUserText !== undefined) {
593
+ lines.push(
594
+ JSON.stringify({
595
+ type: 'message',
596
+ id: `${opts.id}-m1`,
597
+ message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
598
+ }),
599
+ )
600
+ }
601
+ const path = join(dir, opts.name)
602
+ await writeFile(path, lines.join('\n') + '\n')
603
+ return path
604
+ }
605
+
606
+ beforeEach(async () => {
607
+ // agentDir 挂在 mkdtemp 子目录下,legacy 根(dirname(agentDir)/sessions)仍在
608
+ // 自建 tmp 树内,不污染共享 tmpdir(也不受其他用例残留影响)
609
+ root = await mkdtemp(join(tmpdir(), 'find-u11-'))
610
+ agentDir = join(root, 'agent')
611
+ flatMain = join(agentDir, 'sessions')
612
+ legacyDir = join(root, 'sessions')
613
+ })
614
+ afterEach(async () => {
615
+ await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
616
+ })
617
+
618
+ it('① 惰性触发:uuid 精确/归一化路径不调 provider(spy 计数 0)', async () => {
619
+ await writeSession(flatMain, { name: 'a.jsonl', id: '019cafe00001', firstUserText: '无关' })
620
+ const provider = vi.fn(async () => [])
621
+ // 精确子串层命中
622
+ await findSessions('019cafe00001', agentDir, { metadataProvider: provider })
623
+ expect(provider).not.toHaveBeenCalled()
624
+ // 归一化层命中(大写变体:精确层大小写敏感 0 命中,norm 层命中后短路)
625
+ await findSessions('019CAFE00001', agentDir, { metadataProvider: provider })
626
+ expect(provider).not.toHaveBeenCalled()
627
+ })
628
+
629
+ it('② keyword 路径调用 provider 且标题进匹配(name 命中,preview 取元数据首消息免深读)', async () => {
630
+ const pathA = await writeSession(flatMain, {
631
+ name: 'a.jsonl',
632
+ id: 'title-aaaa',
633
+ firstUserText: '首消息完全无关',
634
+ })
635
+ await writeSession(flatMain, { name: 'b.jsonl', id: 'title-bbbb', firstUserText: '另一个无关会话' })
636
+ const provider = vi.fn(async (dir: string) =>
637
+ dir === flatMain
638
+ ? [
639
+ {
640
+ path: pathA,
641
+ id: 'title-aaaa',
642
+ cwd: '/demo',
643
+ name: '福耀玻璃深度研究',
644
+ modified: new Date('2026-09-01'),
645
+ firstMessage: '首消息完全无关',
646
+ },
647
+ ]
648
+ : [],
649
+ )
650
+ const { matches } = await findSessions('福耀玻璃', agentDir, { metadataProvider: provider })
651
+ // 平铺存在的 [default] 根被调用,且永传非空串
652
+ expect(provider).toHaveBeenCalledTimes(1)
653
+ expect(provider.mock.calls[0][0]).toBe(flatMain)
654
+ expect(provider.mock.calls[0][0].length).toBeGreaterThan(0)
655
+ // 标题命中:name 透传 + preview 取元数据 firstMessage
656
+ expect(matches).toHaveLength(1)
657
+ expect(matches[0].sessionId).toBe('title-aaaa')
658
+ expect(matches[0].name).toBe('福耀玻璃深度研究')
659
+ expect(matches[0].firstMessagePreview).toBe('首消息完全无关')
660
+ })
661
+
662
+ it('③ 窄化:含子目录根跳过 provider,候选不退出 keyword 匹配(回退首条 user,标题留空)', async () => {
663
+ // 纯 pi 形态:文件在 encodeCwd 子目录 → [default] 根含子目录 → listAll 跳过
664
+ await writeSession(join(flatMain, '--Users-demo--'), {
665
+ name: 'a.jsonl',
666
+ id: 'nested-aaaa',
667
+ firstUserText: '重构插件架构 plugin review',
668
+ })
669
+ const provider = vi.fn(async () => [])
670
+ const { matches } = await findSessions('plugin', agentDir, { metadataProvider: provider })
671
+ expect(provider).not.toHaveBeenCalled()
672
+ expect(matches).toHaveLength(1)
673
+ expect(matches[0].sessionId).toBe('nested-aaaa')
674
+ expect(matches[0].firstMessagePreview).toContain('plugin')
675
+ // 标题维度降级留空:「能找到 session」不受影响(§6.6 降级路径)
676
+ expect(matches[0].name).toBeUndefined()
677
+ })
678
+
679
+ it('④ guard:provider 抛错降级(标题留空 + 不抛,回退首条 user 匹配)', async () => {
680
+ await writeSession(flatMain, {
681
+ name: 'a.jsonl',
682
+ id: 'err-aaaa',
683
+ firstUserText: '按首条消息命中的文本',
684
+ })
685
+ const provider = vi.fn(async () => {
686
+ throw new Error('listAll boom')
687
+ })
688
+ const { matches } = await findSessions('按首条消息命中', agentDir, {
689
+ metadataProvider: provider,
690
+ })
691
+ expect(provider).toHaveBeenCalledTimes(1) // 调用了但抛错
692
+ expect(matches).toHaveLength(1)
693
+ expect(matches[0].sessionId).toBe('err-aaaa')
694
+ expect(matches[0].name).toBeUndefined()
695
+ expect(matches[0].firstMessagePreview).toContain('按首条消息命中')
696
+ })
697
+
698
+ it('⑤ 多目录结果合并:按 id 去重、重复取新 modified(§11.3b)', async () => {
699
+ const pathA = await writeSession(flatMain, {
700
+ name: 'a.jsonl',
701
+ id: 'dup-id-0001',
702
+ firstUserText: '无关',
703
+ })
704
+ // legacy 根平铺存在(0 文件也算存在根,isFlatRoot 空集恒真)
705
+ await mkdir(legacyDir, { recursive: true })
706
+ const provider = vi.fn(async (dir: string) =>
707
+ dir === flatMain
708
+ ? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: 1000 }]
709
+ : [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: 2000 }],
710
+ )
711
+ const { matches } = await findSessions('新标题', agentDir, { metadataProvider: provider })
712
+ expect(provider).toHaveBeenCalledTimes(2) // 两个平铺存在的根各一次
713
+ expect(matches).toHaveLength(1)
714
+ expect(matches[0].name).toBe('新标题') // 同 id 重复 → 取新 modified 的条目
715
+ const miss = await findSessions('旧标题', agentDir, { metadataProvider: provider })
716
+ expect(miss.matches).toHaveLength(0) // 旧条目已按合并规则丢弃
717
+ })
718
+
719
+ it('⑥ guard:仅对存在的根调用且永传非空串(不存在根 / 不存在 liveSessionDir 不调)', async () => {
720
+ await writeSession(flatMain, { name: 'a.jsonl', id: 'guard-aaaa', firstUserText: '无关' })
721
+ const provider = vi.fn(async () => [])
722
+ await findSessions('zzznohit9q', agentDir, {
723
+ liveSessionDir: join(agentDir, 'no-such-dir'),
724
+ metadataProvider: provider,
725
+ })
726
+ // 仅平铺存在的 [default] 根被调;不存在的 liveSessionDir 与 [legacy] 根均被排除
727
+ expect(provider).toHaveBeenCalledTimes(1)
728
+ expect(provider.mock.calls[0][0]).toBe(flatMain)
729
+ expect(provider.mock.calls.every((c) => typeof c[0] === 'string' && c[0].length > 0)).toBe(true)
730
+ })
731
+
732
+ it('⑥b liveSessionDir 与平铺根同字面路径 → 同目录只调一次(字面路径去重)', async () => {
733
+ await writeSession(flatMain, { name: 'a.jsonl', id: 'dedup-aaaa', firstUserText: '无关' })
734
+ const provider = vi.fn(async () => [])
735
+ await findSessions('zzznohit9q', agentDir, {
736
+ liveSessionDir: flatMain,
737
+ metadataProvider: provider,
738
+ })
739
+ expect(provider).toHaveBeenCalledTimes(1)
740
+ expect(provider.mock.calls[0][0]).toBe(flatMain)
741
+ })
742
+
743
+ it('⑦ recent:仅对 limit 截断后的少数候选补元数据(旧候选所在根不调)', async () => {
744
+ const pNew1 = await writeSession(flatMain, { name: 'n1.jsonl', id: 'recent-new-1' })
745
+ const pNew2 = await writeSession(flatMain, { name: 'n2.jsonl', id: 'recent-new-2' })
746
+ await writeSession(legacyDir, { name: 'o1.jsonl', id: 'recent-old-1' })
747
+ await writeSession(legacyDir, { name: 'o2.jsonl', id: 'recent-old-2' })
748
+ const base = Math.floor(Date.now() / 1000)
749
+ await utimes(pNew1, base + 300, base + 300)
750
+ await utimes(pNew2, base + 200, base + 200)
751
+ await utimes(join(legacyDir, 'o1.jsonl'), base + 100, base + 100)
752
+ await utimes(join(legacyDir, 'o2.jsonl'), base + 50, base + 50)
753
+ const provider = vi.fn(async (dir: string) =>
754
+ dir === flatMain
755
+ ? [
756
+ { path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: (base + 300) * 1000 },
757
+ { path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: (base + 200) * 1000 },
758
+ ]
759
+ : [],
760
+ )
761
+ const { matches, truncated } = await findSessions('recent', agentDir, {
762
+ limit: 2,
763
+ metadataProvider: provider,
764
+ })
765
+ expect(truncated).toBe(true)
766
+ expect(matches.map((m) => m.sessionId)).toEqual(['recent-new-1', 'recent-new-2'])
767
+ // provider 只调最新两个候选所在的平铺根;被截断的 legacy 根候选不触发补元数据
768
+ expect(provider).toHaveBeenCalledTimes(1)
769
+ expect(provider.mock.calls[0][0]).toBe(flatMain)
770
+ // 截断后少数候选补到标题
771
+ expect(matches[0].name).toBe('最新标题一')
772
+ expect(matches[1].name).toBe('最新标题二')
773
+ })
774
+ })