@zhushanwen/pi-session-reader 0.1.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/index.ts +1 -0
- package/package.json +47 -0
- package/src/__tests__/family.test.ts +207 -0
- package/src/__tests__/find.test.ts +214 -0
- package/src/__tests__/hash-provider.test.ts +498 -0
- package/src/__tests__/index.test.ts +193 -0
- package/src/__tests__/parser.test.ts +166 -0
- package/src/__tests__/real-data.ts +53 -0
- package/src/__tests__/render.test.ts +367 -0
- package/src/__tests__/roots.test.ts +132 -0
- package/src/__tests__/session-command.test.ts +198 -0
- package/src/__tests__/subagents.test.ts +430 -0
- package/src/__tests__/tool-handler.test.ts +510 -0
- package/src/__tests__/toolcall.test.ts +256 -0
- package/src/__tests__/tree.test.ts +101 -0
- package/src/__tests__/turns.test.ts +142 -0
- package/src/core/family.ts +240 -0
- package/src/core/parser.ts +160 -0
- package/src/core/render.ts +584 -0
- package/src/core/toolcall.ts +168 -0
- package/src/core/tree.ts +93 -0
- package/src/core/turns.ts +97 -0
- package/src/discovery/find.ts +247 -0
- package/src/discovery/roots.ts +92 -0
- package/src/discovery/subagents.ts +470 -0
- package/src/index.ts +189 -0
- package/src/tool-handler.ts +1160 -0
- package/src/tui/hash-provider.ts +230 -0
- package/src/tui/session-command.ts +85 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { handleSessionRead, renderExtractItems, type SessionReadParams } from '../tool-handler.js'
|
|
7
|
+
import { REAL_AGENT_DIR as REAL, E6, FAM, HAS_E6, HAS_REAL } from './real-data.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* M3 tool-handler 集成测试。
|
|
11
|
+
*
|
|
12
|
+
* 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
|
|
13
|
+
* 传真实 `/Users/zhushanwen/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
|
|
14
|
+
*
|
|
15
|
+
* 覆盖 7 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价。
|
|
16
|
+
*
|
|
17
|
+
* 真实数据用例全部带 skipIf 守卫(CI 无本机 ~/.pi/agent → skip,不硬失败);
|
|
18
|
+
* renderExtractItems F9 截断是纯 fixture,无条件跑。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
22
|
+
it('1. find by uuid fragment returns matching session', async () => {
|
|
23
|
+
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, REAL)
|
|
24
|
+
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
25
|
+
expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
|
|
26
|
+
expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('2. outline yields 32 turns within token budget (v2 O1: <=1500)', async () => {
|
|
30
|
+
const r = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
|
|
31
|
+
const d = r.details as { turns: unknown[]; tokenEstimate: number }
|
|
32
|
+
expect(d.turns.length).toBe(32)
|
|
33
|
+
// v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 600→1500(design §3.3 D4)
|
|
34
|
+
expect(d.tokenEstimate).toBeLessThanOrEqual(1500)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('3. detail single turn returns toolResult summary by default (v2 O3)', async () => {
|
|
38
|
+
const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, REAL)
|
|
39
|
+
const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
|
|
40
|
+
expect(d.entries.length).toBeGreaterThan(0)
|
|
41
|
+
// v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
|
|
42
|
+
expect(d.entries.some((e) => e.type === 'toolResultSummary')).toBe(true)
|
|
43
|
+
// 不再有 role=toolResult 的原文 entry(除非 includeToolResult:true)
|
|
44
|
+
expect(d.entries.some((e) => e.message?.role === 'toolResult')).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('4. family lists fork children and隔代 subagents', async () => {
|
|
48
|
+
const r = await handleSessionRead({ action: 'family', session: FAM }, REAL)
|
|
49
|
+
const d = r.details as {
|
|
50
|
+
forks: Array<{ sessionId: string }>
|
|
51
|
+
subagents: Array<{ sessionId: string }>
|
|
52
|
+
}
|
|
53
|
+
expect(d.forks.some((f) => f.sessionId.startsWith('019fe632'))).toBe(true)
|
|
54
|
+
// 隔代:019fe635.rootSessionId=019fe632(fork 子代),从家族根 FAM 出发仍能关联
|
|
55
|
+
expect(d.subagents.some((s) => s.sessionId.startsWith('019fe635'))).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('5. search pattern returns hits', async () => {
|
|
59
|
+
const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, REAL)
|
|
60
|
+
const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
|
|
61
|
+
expect(d.hits.length).toBeGreaterThan(0)
|
|
62
|
+
expect(typeof d.hits[0].matchSnippet).toBe('string')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('6. export outline materializes a .md file', async () => {
|
|
66
|
+
const r = await handleSessionRead(
|
|
67
|
+
{ action: 'export', session: E6, format: 'outline' },
|
|
68
|
+
REAL,
|
|
69
|
+
)
|
|
70
|
+
const d = r.details as { path: string; sizeBytes: number }
|
|
71
|
+
expect(d.path).toMatch(/\.md$/)
|
|
72
|
+
expect(existsSync(d.path)).toBe(true)
|
|
73
|
+
expect(d.sizeBytes).toBeGreaterThan(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('7. F1 find zero match returns empty matches + 👉 hint (no throw)', async () => {
|
|
77
|
+
const r = await handleSessionRead({ action: 'find', query: 'zzz999' }, REAL)
|
|
78
|
+
const d = r.details as { matches: unknown[]; truncated: boolean }
|
|
79
|
+
expect(d.matches).toEqual([])
|
|
80
|
+
expect(d.truncated).toBe(false)
|
|
81
|
+
expect(r.content[0].text).toContain('👉')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('8. F4 detail turn out of range throws with 越界', async () => {
|
|
85
|
+
await expect(
|
|
86
|
+
handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, REAL),
|
|
87
|
+
).rejects.toThrow(/越界/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('9. F5 outline missing session throws naming session', async () => {
|
|
91
|
+
await expect(handleSessionRead({ action: 'outline' }, REAL)).rejects.toThrow(/session/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('10. resolveSessionId fragment equivalent to full id', async () => {
|
|
95
|
+
const rFull = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
|
|
96
|
+
const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, REAL)
|
|
97
|
+
const dFull = rFull.details as { turns: unknown[] }
|
|
98
|
+
const dFrag = rFrag.details as { turns: unknown[] }
|
|
99
|
+
expect(dFrag.turns.length).toBe(dFull.turns.length)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
104
|
+
it('user-messages returns 26 user entries with turn + full text', async () => {
|
|
105
|
+
const r = await handleSessionRead(
|
|
106
|
+
{ action: 'extract', session: E6, what: 'user-messages' },
|
|
107
|
+
REAL,
|
|
108
|
+
)
|
|
109
|
+
const d = r.details as {
|
|
110
|
+
what: string
|
|
111
|
+
count: number
|
|
112
|
+
shown: number
|
|
113
|
+
truncated: boolean
|
|
114
|
+
items: Array<{ turn: number; text: string }>
|
|
115
|
+
}
|
|
116
|
+
expect(d.what).toBe('user-messages')
|
|
117
|
+
expect(d.count).toBe(26) // 全量 user entry 数(design §1:26 user)
|
|
118
|
+
// F9 预算可能截断:items 是 shown 子集,shown <= count
|
|
119
|
+
expect(d.shown).toBeLessThanOrEqual(d.count)
|
|
120
|
+
expect(d.items.length).toBe(d.shown)
|
|
121
|
+
expect(d.items.length).toBeGreaterThan(0)
|
|
122
|
+
expect(
|
|
123
|
+
d.items.every((it) => typeof it.turn === 'number' && typeof it.text === 'string'),
|
|
124
|
+
).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('commands (no filter) returns 519 tool calls with name + summary', async () => {
|
|
128
|
+
const r = await handleSessionRead(
|
|
129
|
+
{ action: 'extract', session: E6, what: 'commands' },
|
|
130
|
+
REAL,
|
|
131
|
+
)
|
|
132
|
+
const d = r.details as {
|
|
133
|
+
count: number
|
|
134
|
+
shown: number
|
|
135
|
+
items: Array<{ name: string; summary: string }>
|
|
136
|
+
}
|
|
137
|
+
expect(d.count).toBe(519) // 全量 toolCall(design §2.3:519)
|
|
138
|
+
expect(d.shown).toBeLessThanOrEqual(d.count)
|
|
139
|
+
expect(d.items.length).toBe(d.shown)
|
|
140
|
+
expect(
|
|
141
|
+
d.items.every((it) => typeof it.name === 'string' && typeof it.summary === 'string'),
|
|
142
|
+
).toBe(true)
|
|
143
|
+
// 抽查 bash summary 含 "bash: "(D1 映射)
|
|
144
|
+
const bashItem = d.items.find((it) => it.name === 'bash')
|
|
145
|
+
if (bashItem !== undefined) {
|
|
146
|
+
expect(bashItem.summary).toContain('bash: ')
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('commands tool=bash returns 309 all bash', async () => {
|
|
151
|
+
const r = await handleSessionRead(
|
|
152
|
+
{ action: 'extract', session: E6, what: 'commands', tool: 'bash' },
|
|
153
|
+
REAL,
|
|
154
|
+
)
|
|
155
|
+
const d = r.details as {
|
|
156
|
+
count: number
|
|
157
|
+
shown: number
|
|
158
|
+
items: Array<{ name: string }>
|
|
159
|
+
}
|
|
160
|
+
expect(d.count).toBe(309) // bash toolCall 全量(F9 可能截断 shown 子集)
|
|
161
|
+
expect(d.items.every((it) => it.name === 'bash')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
|
|
165
|
+
const r = await handleSessionRead(
|
|
166
|
+
{ action: 'extract', session: E6, what: 'commands', tool: 'nonexist' },
|
|
167
|
+
REAL,
|
|
168
|
+
)
|
|
169
|
+
const d = r.details as {
|
|
170
|
+
toolDistribution: Array<{ name: string; count: number }>
|
|
171
|
+
}
|
|
172
|
+
expect(r.content[0].text).toContain('无匹配')
|
|
173
|
+
expect(r.content[0].text).toContain('bash×309')
|
|
174
|
+
expect(r.content[0].text).toContain('👉')
|
|
175
|
+
expect(d.toolDistribution.some((t) => t.name === 'bash' && t.count === 309)).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('files returns deduped paths with op (read/edit/write/head)', async () => {
|
|
179
|
+
const r = await handleSessionRead(
|
|
180
|
+
{ action: 'extract', session: E6, what: 'files' },
|
|
181
|
+
REAL,
|
|
182
|
+
)
|
|
183
|
+
const d = r.details as {
|
|
184
|
+
count: number
|
|
185
|
+
items: Array<{ path: string; basename: string; op: string; turns: number[] }>
|
|
186
|
+
}
|
|
187
|
+
expect(d.count).toBeGreaterThan(0) // 开发 session 有大量文件操作
|
|
188
|
+
expect(d.items.length).toBeGreaterThan(0)
|
|
189
|
+
// 抽查含 .ts 或 .md 文件
|
|
190
|
+
expect(
|
|
191
|
+
d.items.some((it) => it.path.endsWith('.ts') || it.path.endsWith('.md')),
|
|
192
|
+
).toBe(true)
|
|
193
|
+
// op 含 read/edit/write/head 之一
|
|
194
|
+
expect(d.items.some((it) => /read|edit|write|head/.test(it.op))).toBe(true)
|
|
195
|
+
// turns 是数组(去重后出现过的轮次)
|
|
196
|
+
expect(d.items.every((it) => Array.isArray(it.turns))).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('commits returns hash list with 7-8 hex + source turn', async () => {
|
|
200
|
+
const r = await handleSessionRead(
|
|
201
|
+
{ action: 'extract', session: E6, what: 'commits' },
|
|
202
|
+
REAL,
|
|
203
|
+
)
|
|
204
|
+
const d = r.details as {
|
|
205
|
+
count: number
|
|
206
|
+
items: Array<{ hash: string; turn: number; source: string; context: string }>
|
|
207
|
+
}
|
|
208
|
+
// 真实开发 session 有 git log/show 操作 → git-cmd 主路径应取到 hash
|
|
209
|
+
expect(d.count).toBeGreaterThan(0)
|
|
210
|
+
// commits 已知会误匹配/漏报(D6),只验每条格式
|
|
211
|
+
for (const it of d.items) {
|
|
212
|
+
expect(it.hash).toMatch(/^[0-9a-f]{7,8}$/)
|
|
213
|
+
expect(typeof it.turn).toBe('number')
|
|
214
|
+
expect(['git-cmd', 'commit-context']).toContain(it.source)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('tool-results (no filter) returns 515 results with toolName + text', async () => {
|
|
219
|
+
const r = await handleSessionRead(
|
|
220
|
+
{ action: 'extract', session: E6, what: 'tool-results' },
|
|
221
|
+
REAL,
|
|
222
|
+
)
|
|
223
|
+
const d = r.details as {
|
|
224
|
+
count: number
|
|
225
|
+
shown: number
|
|
226
|
+
items: Array<{ toolName: string; text: string }>
|
|
227
|
+
}
|
|
228
|
+
expect(d.count).toBe(515) // 全量 toolResult(design §1:515 toolResult)
|
|
229
|
+
expect(d.shown).toBeLessThanOrEqual(d.count)
|
|
230
|
+
expect(
|
|
231
|
+
d.items.every((it) => typeof it.toolName === 'string' && typeof it.text === 'string'),
|
|
232
|
+
).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('tool-results tool=bash returns 309 all bash', async () => {
|
|
236
|
+
const r = await handleSessionRead(
|
|
237
|
+
{ action: 'extract', session: E6, what: 'tool-results', tool: 'bash' },
|
|
238
|
+
REAL,
|
|
239
|
+
)
|
|
240
|
+
const d = r.details as { count: number; items: Array<{ toolName: string }> }
|
|
241
|
+
expect(d.count).toBe(309)
|
|
242
|
+
expect(d.items.every((it) => it.toolName === 'bash')).toBe(true)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
|
|
246
|
+
const r = await handleSessionRead(
|
|
247
|
+
{ action: 'extract', session: E6, what: 'tool-results', tool: 'nonexist' },
|
|
248
|
+
REAL,
|
|
249
|
+
)
|
|
250
|
+
expect(r.content[0].text).toContain('无匹配')
|
|
251
|
+
expect(r.content[0].text).toContain('bash×309')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('F7 missing what throws with 无效 + valid values', async () => {
|
|
255
|
+
// what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
|
|
256
|
+
// isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
|
|
257
|
+
const params: SessionReadParams = { action: 'extract', session: E6 }
|
|
258
|
+
await expect(handleSessionRead(params, REAL)).rejects.toThrow(/无效/)
|
|
259
|
+
await expect(handleSessionRead(params, REAL)).rejects.toThrow(
|
|
260
|
+
/user-messages\/commands\/files\/commits\/tool-results/,
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('S2: extract turns 越界文案说明 extract turn 体系(不指向 outline)', async () => {
|
|
265
|
+
// extract 用全量 entry 分段(含 compaction/旁支),turn index 与 outline leaf 视图不对齐
|
|
266
|
+
let msg = ''
|
|
267
|
+
try {
|
|
268
|
+
await handleSessionRead(
|
|
269
|
+
{ action: 'extract', session: E6, what: 'user-messages', turns: 'T999' },
|
|
270
|
+
REAL,
|
|
271
|
+
)
|
|
272
|
+
} catch (e) {
|
|
273
|
+
msg = (e as Error).message
|
|
274
|
+
}
|
|
275
|
+
expect(msg).toContain('extract 的 turn 范围与 outline 不同')
|
|
276
|
+
expect(msg).not.toContain('用 outline 重看有效范围')
|
|
277
|
+
expect(msg).toContain('该 session extract 共')
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// ---- fixture 工具(tmpdir 造最小 session 文件,供 F2/MF-5 用例)----
|
|
282
|
+
|
|
283
|
+
async function makeFixtureSession(
|
|
284
|
+
dir: string,
|
|
285
|
+
id: string,
|
|
286
|
+
firstUserText: string,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const slug = '--demo-cwd--'
|
|
289
|
+
await mkdir(join(dir, 'sessions', slug), { recursive: true })
|
|
290
|
+
const lines = [
|
|
291
|
+
JSON.stringify({ type: 'session', id, cwd: '/demo' }),
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
type: 'message',
|
|
294
|
+
id: id + '-m1',
|
|
295
|
+
parentId: id,
|
|
296
|
+
message: { role: 'user', content: [{ type: 'text', text: firstUserText }] },
|
|
297
|
+
}),
|
|
298
|
+
]
|
|
299
|
+
await writeFile(join(dir, 'sessions', slug, id + '.jsonl'), lines.join('\n') + '\n')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
303
|
+
let dir: string
|
|
304
|
+
|
|
305
|
+
beforeEach(async () => {
|
|
306
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-f2-'))
|
|
307
|
+
})
|
|
308
|
+
afterEach(async () => {
|
|
309
|
+
await rm(dir, { recursive: true, force: true })
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('共享 uuid 片段 → 不抛错,content 含两候选 + 👉,details.ambiguous=true', async () => {
|
|
313
|
+
const ID1 = '019e6c96-aaaa-bbbb-cccc-000000000001'
|
|
314
|
+
const ID2 = '019e6c96-aaaa-bbbb-cccc-000000000002'
|
|
315
|
+
await makeFixtureSession(dir, ID1, '第一段内容')
|
|
316
|
+
await makeFixtureSession(dir, ID2, '第二段内容')
|
|
317
|
+
|
|
318
|
+
// outline 走 resolveSessionId → 2 匹配 → F2 消歧(不抛错)
|
|
319
|
+
const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, dir)
|
|
320
|
+
const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
|
|
321
|
+
expect(d.ambiguous).toBe(true)
|
|
322
|
+
expect(d.candidates).toHaveLength(2)
|
|
323
|
+
expect(d.candidates.some((c) => c.sessionId === ID1)).toBe(true)
|
|
324
|
+
expect(d.candidates.some((c) => c.sessionId === ID2)).toBe(true)
|
|
325
|
+
const text = r.content[0].text
|
|
326
|
+
expect(text).toContain(ID1)
|
|
327
|
+
expect(text).toContain(ID2)
|
|
328
|
+
expect(text).toContain('👉')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('search/detail/expand/extract 同样走 F2 消歧(不抛错)', async () => {
|
|
332
|
+
const ID1 = '019e6c96-aaaa-bbbb-cccc-000000000001'
|
|
333
|
+
const ID2 = '019e6c96-aaaa-bbbb-cccc-000000000002'
|
|
334
|
+
await makeFixtureSession(dir, ID1, '第一段内容')
|
|
335
|
+
await makeFixtureSession(dir, ID2, '第二段内容')
|
|
336
|
+
|
|
337
|
+
for (const action of ['detail', 'expand', 'search', 'export', 'extract'] as const) {
|
|
338
|
+
const params: SessionReadParams = { action, session: '019e6c96-aaaa' }
|
|
339
|
+
if (action === 'detail') params.turns = 'T001'
|
|
340
|
+
if (action === 'expand') params.turn = 'T001'
|
|
341
|
+
if (action === 'search') params.pattern = 'x'
|
|
342
|
+
if (action === 'extract') params.what = 'user-messages'
|
|
343
|
+
const r = await handleSessionRead(params, dir)
|
|
344
|
+
const d = r.details as { ambiguous: boolean }
|
|
345
|
+
expect(d.ambiguous, `action=${action}`).toBe(true)
|
|
346
|
+
expect(r.content[0].text).toContain('👉')
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
|
|
352
|
+
let dir: string
|
|
353
|
+
const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
|
|
354
|
+
|
|
355
|
+
beforeEach(async () => {
|
|
356
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-search-'))
|
|
357
|
+
await makeFixtureSession(dir, SID, 'aaa plugin 内容')
|
|
358
|
+
})
|
|
359
|
+
afterEach(async () => {
|
|
360
|
+
await rm(dir, { recursive: true, force: true })
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
|
|
364
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
|
|
365
|
+
const d = r.details as { hits: unknown[] }
|
|
366
|
+
// 字面量 '(a+)+' 不在内容里 → 0 命中(若按正则执行会命中 'aaa' 且可能指数回溯)
|
|
367
|
+
expect(d.hits).toHaveLength(0)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('普通正则仍按正则匹配', async () => {
|
|
371
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, dir)
|
|
372
|
+
const d = r.details as { hits: unknown[] }
|
|
373
|
+
expect(d.hits.length).toBeGreaterThan(0)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('范围量词 {m,n} 形态 → 降级字面子串((a{1,3})*、(a{0,2})*、(a{1,3}){2,},MF-1 回归)', async () => {
|
|
377
|
+
for (const pattern of ['(a{1,3})*', '(a{0,2})*', '(a{1,3}){2,}']) {
|
|
378
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
|
|
379
|
+
const d = r.details as { hits: unknown[] }
|
|
380
|
+
// 字面量不含这些 pattern → 0 命中;若按正则执行会指数回溯挂死
|
|
381
|
+
expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('alternation/嵌套量词分支 (a|aa)+、(a*)* → 降级字面子串(MF-5 分支回归)', async () => {
|
|
386
|
+
for (const pattern of ['(a|aa)+', '(a*)*']) {
|
|
387
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
|
|
388
|
+
const d = r.details as { hits: unknown[] }
|
|
389
|
+
expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('(a{1,3}) 单独使用不被降级(组后无尾随量词,仍按正则执行)', async () => {
|
|
394
|
+
const r = await handleSessionRead(
|
|
395
|
+
{ action: 'search', session: SID, pattern: '(a{1,3})' },
|
|
396
|
+
dir,
|
|
397
|
+
)
|
|
398
|
+
const d = r.details as { hits: unknown[] }
|
|
399
|
+
// 按正则执行命中 'aaa' → >0 命中(若被降级为字面量则 0 命中)
|
|
400
|
+
expect(d.hits.length).toBeGreaterThan(0)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('降级标注:header 含「已降级为字面子串匹配」(S-3)', async () => {
|
|
404
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
|
|
405
|
+
expect(r.content[0].text).toContain('已降级为字面子串匹配')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('非法正则 pattern → 字面子串兜底不抛错(零命中,S-6)', async () => {
|
|
409
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, dir)
|
|
410
|
+
const d = r.details as { hits: unknown[] }
|
|
411
|
+
expect(d.hits).toHaveLength(0)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('scope 过滤:scope=assistant 零命中(fixture 仅 user 角色,S-6)', async () => {
|
|
415
|
+
const r = await handleSessionRead(
|
|
416
|
+
{ action: 'search', session: SID, pattern: 'aaa', scope: 'assistant' },
|
|
417
|
+
dir,
|
|
418
|
+
)
|
|
419
|
+
const d = r.details as { hits: unknown[] }
|
|
420
|
+
expect(d.hits).toHaveLength(0)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('limit 截断 → truncated=true + hits 限长(3 条命中 limit=2,S-6)', async () => {
|
|
424
|
+
const sid2 = '019e6c96-bbbb-cccc-dddd-00000000000b'
|
|
425
|
+
// message 链式 parentId(与真实 pi session 一致):m0→session 根,m1→m0,m2→m1,
|
|
426
|
+
// 否则 buildTreeView 只回溯最后一条的父链,前两条被当旁支过滤
|
|
427
|
+
const lines = [
|
|
428
|
+
JSON.stringify({ type: 'session', id: sid2, cwd: '/demo' }),
|
|
429
|
+
...['aa1', 'aa2', 'aa3'].map((t, i) =>
|
|
430
|
+
JSON.stringify({
|
|
431
|
+
type: 'message',
|
|
432
|
+
id: `${sid2}-m${i}`,
|
|
433
|
+
parentId: i === 0 ? sid2 : `${sid2}-m${i - 1}`,
|
|
434
|
+
message: { role: 'user', content: [{ type: 'text', text: t }] },
|
|
435
|
+
}),
|
|
436
|
+
),
|
|
437
|
+
]
|
|
438
|
+
await writeFile(
|
|
439
|
+
join(dir, 'sessions', '--demo-cwd--', `${sid2}.jsonl`),
|
|
440
|
+
lines.join('\n') + '\n',
|
|
441
|
+
)
|
|
442
|
+
const r = await handleSessionRead(
|
|
443
|
+
{ action: 'search', session: sid2, pattern: 'aa', limit: 2 },
|
|
444
|
+
dir,
|
|
445
|
+
)
|
|
446
|
+
const d = r.details as { hits: unknown[]; truncated: boolean }
|
|
447
|
+
expect(d.truncated).toBe(true)
|
|
448
|
+
expect(d.hits).toHaveLength(2)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('aborted signal → search 抛中断错误(不继续扫描)', async () => {
|
|
452
|
+
const ac = new AbortController()
|
|
453
|
+
ac.abort()
|
|
454
|
+
await expect(
|
|
455
|
+
handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, dir, ac.signal),
|
|
456
|
+
).rejects.toThrow(/中断/)
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
describe('renderExtractItems F9 截断(S3 首项超大 + S4 文案)', () => {
|
|
461
|
+
it('S3: 首项超大内部截断 → body 不超预算,shown<count + truncated=true', () => {
|
|
462
|
+
// 首项 10000 字符 >> EXTRACT_BUDGET_BYTES(8000);旧逻辑放行首项致 body≈10KB
|
|
463
|
+
const longText = 'x'.repeat(10000)
|
|
464
|
+
const items = [
|
|
465
|
+
{ turn: 5, text: longText },
|
|
466
|
+
{ turn: 6, text: 'short6' },
|
|
467
|
+
{ turn: 7, text: 'short7' },
|
|
468
|
+
]
|
|
469
|
+
const r = renderExtractItems(
|
|
470
|
+
'user-messages',
|
|
471
|
+
items,
|
|
472
|
+
(it) => `T${String(it.turn).padStart(3, '0')}: ${it.text}`,
|
|
473
|
+
(it) => [it.turn],
|
|
474
|
+
)
|
|
475
|
+
const d = r.details as { what: string; count: number; shown: number; truncated: boolean }
|
|
476
|
+
expect(d.truncated).toBe(true)
|
|
477
|
+
expect(d.count).toBe(3)
|
|
478
|
+
expect(d.shown).toBe(1) // 首项截断后保留,后续项因预算用未加入
|
|
479
|
+
// body(含文案行)字节数远小于 3 项全量(30000+ 字节),≤ 预算 + 合理余量
|
|
480
|
+
const bodyBytes = Buffer.byteLength(r.content[0].text, 'utf8')
|
|
481
|
+
expect(bodyBytes).toBeLessThan(8000 * 1.5)
|
|
482
|
+
// 截断标记存在(首项被内部 slice + 省略号)
|
|
483
|
+
expect(r.content[0].text).toContain('…')
|
|
484
|
+
// 完整 longText 不在输出里(已被截断)
|
|
485
|
+
expect(r.content[0].text).not.toContain(longText)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('S4: F9 文案含实际 turn 范围 + 实际 token(非固定 2000)', () => {
|
|
489
|
+
// 3 项各 ~3000 字节,累计超 8000 → 第 3 项触发截断(文案报 shown turn 范围)
|
|
490
|
+
const items = [
|
|
491
|
+
{ turn: 10, text: 'a'.repeat(3000) },
|
|
492
|
+
{ turn: 20, text: 'b'.repeat(3000) },
|
|
493
|
+
{ turn: 30, text: 'c'.repeat(3000) },
|
|
494
|
+
]
|
|
495
|
+
const r = renderExtractItems(
|
|
496
|
+
'user-messages',
|
|
497
|
+
items,
|
|
498
|
+
(it) => `T${String(it.turn).padStart(3, '0')}: ${it.text}`,
|
|
499
|
+
(it) => [it.turn],
|
|
500
|
+
)
|
|
501
|
+
const text = r.content[0].text
|
|
502
|
+
// 文案含 turn 范围(shown 的 min-max turn)
|
|
503
|
+
expect(text).toMatch(/T010-T0\d{2}/)
|
|
504
|
+
// 文案含实际 token(从 body 实际字节算),不再是固定 2000
|
|
505
|
+
expect(text).toContain('token 达预算上限')
|
|
506
|
+
expect(text).not.toContain('≈2000 token')
|
|
507
|
+
// 文案含 shown/count
|
|
508
|
+
expect(text).toMatch(/已显示 \d+\/\d+ 项/)
|
|
509
|
+
})
|
|
510
|
+
})
|