@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,498 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
extractHashFragment,
|
|
7
|
+
formatAge,
|
|
8
|
+
toCandidate,
|
|
9
|
+
provideHashCandidates,
|
|
10
|
+
createHashAutocompleteProvider,
|
|
11
|
+
type AutocompleteCandidate,
|
|
12
|
+
} from '../tui/hash-provider.js'
|
|
13
|
+
import type { SessionInfo } from '@earendil-works/pi-coding-agent'
|
|
14
|
+
import type { AutocompleteProvider } from '@earendil-works/pi-tui'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TUI 层纯逻辑单测(design §3.3 D-3/D-4 + 2026-08-10 重构)。
|
|
18
|
+
*
|
|
19
|
+
* 测三层:
|
|
20
|
+
* 1. extractHashFragment:# 片段提取 + token 边界
|
|
21
|
+
* 2. toCandidate / formatAge:SessionInfo → 候选转换 + 单单位时间
|
|
22
|
+
* 3. provideHashCandidates:端到端(真实 tmpdir 造 session 文件,SessionManager.listAll 真跑,不 mock)
|
|
23
|
+
*
|
|
24
|
+
* insertText 方案:完整 uuid(# + 36 字符),findSessions 子串匹配零碰撞。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ---- fixture helper(造真实 pi session 文件,让 listAll 真实解析)----
|
|
28
|
+
|
|
29
|
+
async function makeSession(
|
|
30
|
+
dir: string,
|
|
31
|
+
opts: {
|
|
32
|
+
fileName: string
|
|
33
|
+
id: string
|
|
34
|
+
cwd?: string
|
|
35
|
+
name?: string
|
|
36
|
+
firstUserText?: string
|
|
37
|
+
},
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const header: Record<string, unknown> = {
|
|
40
|
+
type: 'session',
|
|
41
|
+
version: 3,
|
|
42
|
+
id: opts.id,
|
|
43
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
44
|
+
}
|
|
45
|
+
if (opts.cwd) header.cwd = opts.cwd
|
|
46
|
+
const lines: unknown[] = [header]
|
|
47
|
+
if (opts.name) {
|
|
48
|
+
lines.push({ type: 'session_info', id: opts.id + '-info', name: opts.name })
|
|
49
|
+
}
|
|
50
|
+
if (opts.firstUserText) {
|
|
51
|
+
lines.push({
|
|
52
|
+
type: 'message',
|
|
53
|
+
id: opts.id + '-m1',
|
|
54
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
await mkdir(dir, { recursive: true })
|
|
58
|
+
await writeFile(join(dir, opts.fileName), lines.map((o) => JSON.stringify(o)).join('\n') + '\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 造 SessionInfo 对象(toCandidate 纯函数测试用,不经 listAll)。 */
|
|
62
|
+
function makeSessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|
63
|
+
return {
|
|
64
|
+
path: '/fake/019e6c96.jsonl',
|
|
65
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
66
|
+
cwd: '/demo',
|
|
67
|
+
created: new Date('2026-01-01T00:00:00.000Z'),
|
|
68
|
+
modified: new Date('2026-01-01T00:00:00.000Z'),
|
|
69
|
+
messageCount: 14,
|
|
70
|
+
firstMessage: '修复登录 bug',
|
|
71
|
+
allMessagesText: '',
|
|
72
|
+
...overrides,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- extractHashFragment ----
|
|
77
|
+
|
|
78
|
+
describe('extractHashFragment', () => {
|
|
79
|
+
it('行首 # + hex 片段', () => {
|
|
80
|
+
expect(extractHashFragment('#e6c96')).toBe('e6c96')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('空片段(刚输入 #)', () => {
|
|
84
|
+
expect(extractHashFragment('#')).toBe('')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('# 前是空格(token 边界)→ 命中', () => {
|
|
88
|
+
expect(extractHashFragment('see #e6c9')).toBe('e6c9')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('# 前是单词字符(foo#bar)→ null(防 hashtag 误触发)', () => {
|
|
92
|
+
expect(extractHashFragment('foo#bar')).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('非 # 前缀(纯文本)→ null', () => {
|
|
96
|
+
expect(extractHashFragment('hello world')).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('/ 命令前缀 → null(委托命令 provider)', () => {
|
|
100
|
+
expect(extractHashFragment('/session-pick')).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('@ 文件前缀 → null(委托文件 provider)', () => {
|
|
104
|
+
expect(extractHashFragment('@path/to')).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('片段含连字符(完整 uuid 片段)', () => {
|
|
108
|
+
expect(extractHashFragment('#019e6c96-aaaa')).toBe('019e6c96-aaaa')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('# 后跟非 hex 字符(#hello)→ null(非 uuid 片段,委托下家 provider)', () => {
|
|
112
|
+
expect(extractHashFragment('#hello')).toBeNull()
|
|
113
|
+
expect(extractHashFragment('#bug')).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ---- formatAge(单单位,对齐 /resume formatSessionDate)----
|
|
118
|
+
|
|
119
|
+
describe('formatAge', () => {
|
|
120
|
+
const now = new Date('2026-01-15T12:00:00Z').getTime()
|
|
121
|
+
|
|
122
|
+
it('< 1 分钟 → now', () => {
|
|
123
|
+
expect(formatAge(now - 30_000, now)).toBe('now')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('< 1 小时 → XXm(2位补零)', () => {
|
|
127
|
+
expect(formatAge(now - 5 * 60_000, now)).toBe('05m')
|
|
128
|
+
expect(formatAge(now - 59 * 60_000, now)).toBe('59m')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('< 1 天 → XXh', () => {
|
|
132
|
+
expect(formatAge(now - 3 * 3_600_000, now)).toBe('03h')
|
|
133
|
+
expect(formatAge(now - 23 * 3_600_000, now)).toBe('23h')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('< 7 天 → XXd', () => {
|
|
137
|
+
expect(formatAge(now - 2 * 86_400_000, now)).toBe('02d')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('< 30 天 → XXw', () => {
|
|
141
|
+
expect(formatAge(now - 14 * 86_400_000, now)).toBe('02w')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('< 365 天 → XXM(月单位 M,单字符与分钟 m 区分)', () => {
|
|
145
|
+
expect(formatAge(now - 60 * 86_400_000, now)).toBe('02M')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('≥ 365 天 → XXy', () => {
|
|
149
|
+
expect(formatAge(now - 400 * 86_400_000, now)).toBe('01y')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('接收 Date 对象(SessionInfo.modified 是 Date)', () => {
|
|
153
|
+
expect(formatAge(new Date(now - 2 * 3_600_000), now)).toBe('02h')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('未来时间(时钟偏移)→ now', () => {
|
|
157
|
+
expect(formatAge(now + 10_000, now)).toBe('now')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('固定等宽:除 now 外都是 3 字符(XXu,2位数字+1位单位,design G4 对齐)', () => {
|
|
161
|
+
// 各档位抽样,都应 3 字符
|
|
162
|
+
expect(formatAge(now - 90 * 60_000, now)).toBe('01h') // 90分=1h,补零
|
|
163
|
+
expect(formatAge(now - 5 * 60_000, now).length).toBe(3)
|
|
164
|
+
expect(formatAge(now - 3 * 3_600_000, now).length).toBe(3)
|
|
165
|
+
expect(formatAge(now - 2 * 86_400_000, now).length).toBe(3)
|
|
166
|
+
expect(formatAge(now - 60 * 86_400_000, now).length).toBe(3)
|
|
167
|
+
expect(formatAge(now - 400 * 86_400_000, now).length).toBe(3)
|
|
168
|
+
expect(formatAge(now - 30_000, now)).toBe('now') // now 也是 3 字符
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ---- toCandidate(SessionInfo → AutocompleteCandidate)----
|
|
173
|
+
|
|
174
|
+
describe('toCandidate', () => {
|
|
175
|
+
it('insertText = # + 完整 sessionId(36 字符 uuid,design D-3)', () => {
|
|
176
|
+
const c = toCandidate(makeSessionInfo())
|
|
177
|
+
expect(c.insertText).toBe('#019e6c96-0a0c-74b8-a73f-d1854d88e2a7')
|
|
178
|
+
// 完整 uuid = 36 字符,加 # 前缀 = 37
|
|
179
|
+
expect(c.insertText).toHaveLength(37)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('label = `${age} ${预览}`(时间最左 + 1 空格 + 预览;不设 description 触发满宽分支)', () => {
|
|
183
|
+
const c = toCandidate(
|
|
184
|
+
makeSessionInfo({ firstMessage: '修复登录 bug', messageCount: 14 }),
|
|
185
|
+
new Date('2026-01-01T13:00:00Z').getTime(),
|
|
186
|
+
)
|
|
187
|
+
// modified=2026-01-01T00:00, now=13:00 → 13h
|
|
188
|
+
expect(c.label).toBe('13h 修复登录 bug')
|
|
189
|
+
// description 未设(undefined)——触发 SelectList 满宽 label 分支,绕过主列固定32
|
|
190
|
+
expect(c.description).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('有 name → label 放 name 不放 firstMessage(design G3)', () => {
|
|
194
|
+
const c = toCandidate(
|
|
195
|
+
makeSessionInfo({ name: 'my-session', firstMessage: '首条消息内容', messageCount: 5 }),
|
|
196
|
+
new Date('2026-01-01T01:00:00Z').getTime(),
|
|
197
|
+
)
|
|
198
|
+
expect(c.label).toBe('01h my-session')
|
|
199
|
+
expect(c.label).not.toContain('首条消息内容')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('firstMessage 超长 → 截断到 PREVIEW_MAX(避免传超大字符串给 pi-tui)', () => {
|
|
203
|
+
const longText = 'X'.repeat(500)
|
|
204
|
+
const c = toCandidate(makeSessionInfo({ firstMessage: longText }), new Date('2026-01-01T01:00:00Z').getTime())
|
|
205
|
+
// label = age(3) + 空格(1) + 截断text(100+…)
|
|
206
|
+
expect(c.label).toMatch(/^01h X{100}…$/)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('label 不含 uuid、不含 count(用户反馈:不显示 uuid、不显示 count)', () => {
|
|
210
|
+
const c = toCandidate(makeSessionInfo({ messageCount: 999 }))
|
|
211
|
+
expect(c.label).not.toContain('019e6c96')
|
|
212
|
+
expect(c.label).not.toContain('999')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('firstMessage 含换行/控制符 → 清洗为单空格(避免破坏 SelectList 单行渲染)', () => {
|
|
216
|
+
const c = toCandidate(makeSessionInfo({ firstMessage: '第一行\n第二行\t缩进' }))
|
|
217
|
+
expect(c.label).not.toContain('\n')
|
|
218
|
+
expect(c.label).not.toContain('\t')
|
|
219
|
+
expect(c.label).toContain('第一行 第二行 缩进')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('无 name 且无 firstMessage → 标 (无预览)', () => {
|
|
223
|
+
const c = toCandidate(makeSessionInfo({ name: undefined, firstMessage: '' }))
|
|
224
|
+
expect(c.label).toContain('(无预览)')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ---- provideHashCandidates(端到端,真实 tmpdir,listAll 真跑不 mock)----
|
|
229
|
+
// fixture:agentDir/sessions/<cwdA|cwdB>/ 结构(模拟真实 pi 布局)。
|
|
230
|
+
// listAll(cwdSessionDir) 扫单 cwd;insertText 始终完整 uuid(findSessions 子串匹配零碰撞)。
|
|
231
|
+
|
|
232
|
+
describe('provideHashCandidates', () => {
|
|
233
|
+
let agentDir: string
|
|
234
|
+
let cwdSessionDir: string
|
|
235
|
+
let otherCwdDir: string
|
|
236
|
+
|
|
237
|
+
beforeEach(async () => {
|
|
238
|
+
agentDir = await mkdtemp(join(tmpdir(), 'hash-test-'))
|
|
239
|
+
cwdSessionDir = join(agentDir, 'sessions', 'cwdA')
|
|
240
|
+
otherCwdDir = join(agentDir, 'sessions', 'cwdB')
|
|
241
|
+
await mkdir(cwdSessionDir, { recursive: true })
|
|
242
|
+
await mkdir(otherCwdDir, { recursive: true })
|
|
243
|
+
})
|
|
244
|
+
afterEach(async () => {
|
|
245
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('非 # 输入 → null(委托下家 provider)', async () => {
|
|
249
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7', cwd: '/demo' })
|
|
250
|
+
const result = await provideHashCandidates('hello world', cwdSessionDir)
|
|
251
|
+
expect(result).toBeNull()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('# 空片段 → recent 候选(当前目录全部 session),insertText 完整 uuid', async () => {
|
|
255
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7', cwd: '/demo' })
|
|
256
|
+
await makeSession(cwdSessionDir, { fileName: 'b.jsonl', id: '019fffff-1111-2222-3333-444455556666', cwd: '/demo' })
|
|
257
|
+
const result = await provideHashCandidates('#', cwdSessionDir)
|
|
258
|
+
expect(result).not.toBeNull()
|
|
259
|
+
expect(result!.length).toBe(2)
|
|
260
|
+
// insertText 完整 uuid(# + 36 字符 = 37)
|
|
261
|
+
expect(result!.every((c) => c.insertText.length === 37 && c.insertText.startsWith('#'))).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('#e6c9 片段 → id 子串过滤', async () => {
|
|
265
|
+
await makeSession(cwdSessionDir, {
|
|
266
|
+
fileName: 'a.jsonl',
|
|
267
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
268
|
+
cwd: '/demo',
|
|
269
|
+
firstUserText: '修复登录 bug',
|
|
270
|
+
})
|
|
271
|
+
await makeSession(cwdSessionDir, {
|
|
272
|
+
fileName: 'b.jsonl',
|
|
273
|
+
id: '019fffff-1111-2222-3333-444455556666',
|
|
274
|
+
cwd: '/demo',
|
|
275
|
+
firstUserText: '无关内容',
|
|
276
|
+
})
|
|
277
|
+
const result = await provideHashCandidates('#e6c9', cwdSessionDir)
|
|
278
|
+
expect(result).not.toBeNull()
|
|
279
|
+
expect(result!).toHaveLength(1)
|
|
280
|
+
expect(result![0].insertText).toBe('#019e6c96-0a0c-74b8-a73f-d1854d88e2a7')
|
|
281
|
+
// label = age+预览(不含 uuid、不含 count);description 未设
|
|
282
|
+
expect(result![0].label).not.toContain('019e6c96')
|
|
283
|
+
expect(result![0].label).toContain('修复登录 bug')
|
|
284
|
+
expect(result![0].description).toBeUndefined()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('insertText 格式 #完整 uuid(8-4-4-4-12)', async () => {
|
|
288
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7', cwd: '/demo' })
|
|
289
|
+
const result = await provideHashCandidates('#019e', cwdSessionDir)
|
|
290
|
+
expect(result).not.toBeNull()
|
|
291
|
+
const c = result![0] as AutocompleteCandidate
|
|
292
|
+
expect(c.insertText).toMatch(/^#[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
|
293
|
+
expect(c.insertText).toBe('#019e6c96-0a0c-74b8-a73f-d1854d88e2a7')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('# 片段无匹配 → 空数组(不抛)', async () => {
|
|
297
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7', cwd: '/demo' })
|
|
298
|
+
const result = await provideHashCandidates('#deadbeef', cwdSessionDir)
|
|
299
|
+
expect(result).not.toBeNull()
|
|
300
|
+
expect(result).toEqual([])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('limit 限制返回数量', async () => {
|
|
304
|
+
for (let i = 0; i < 5; i++) {
|
|
305
|
+
await makeSession(cwdSessionDir, {
|
|
306
|
+
fileName: `f${i}.jsonl`,
|
|
307
|
+
id: `019e6c9${i}-0000-0000-0000-00000000000${i}`,
|
|
308
|
+
cwd: '/demo',
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
const result = await provideHashCandidates('#019e6c9', cwdSessionDir, { limit: 2 })
|
|
312
|
+
expect(result).not.toBeNull()
|
|
313
|
+
expect(result!).toHaveLength(2)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('空 cwdSessionDir → 返回空数组,不调 listAll(避免全盘扫描卡死)', async () => {
|
|
317
|
+
const result = await provideHashCandidates('#abc', '')
|
|
318
|
+
expect(result).toEqual([])
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('visible 只扫当前 cwd 目录(design G1)', async () => {
|
|
322
|
+
// cwdA(当前)有 1 个
|
|
323
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7', cwd: '/demo' })
|
|
324
|
+
// otherCwdDir(其他 cwd)放一个不同前缀的,listAll(cwdA) 不扫它
|
|
325
|
+
await makeSession(otherCwdDir, { fileName: 'other.jsonl', id: '019fffff-1111-2222-3333-444455556666', cwd: '/other' })
|
|
326
|
+
const result = await provideHashCandidates('#', cwdSessionDir)
|
|
327
|
+
expect(result).not.toBeNull()
|
|
328
|
+
expect(result!).toHaveLength(1)
|
|
329
|
+
expect(result![0].insertText).toBe('#019e6c96-0a0c-74b8-a73f-d1854d88e2a7')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('有 name 的 session → label 含 name 不含 firstMessage(design G3)', async () => {
|
|
333
|
+
await makeSession(cwdSessionDir, {
|
|
334
|
+
fileName: 'named.jsonl',
|
|
335
|
+
id: '019fffff-1111-2222-3333-444455556666',
|
|
336
|
+
cwd: '/demo',
|
|
337
|
+
name: 'my-named-session',
|
|
338
|
+
firstUserText: '首条消息内容',
|
|
339
|
+
})
|
|
340
|
+
const result = await provideHashCandidates('#019f', cwdSessionDir)
|
|
341
|
+
expect(result!).toHaveLength(1)
|
|
342
|
+
expect(result![0].label).toContain('my-named-session')
|
|
343
|
+
expect(result![0].label).not.toContain('首条消息内容')
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// ---- createHashAutocompleteProvider(wrapper 层,MF-8:此前零测试)----
|
|
348
|
+
// fake current provider + fake signal,断言委托/不委托/早退;applyCompletion 前缀替换手术。
|
|
349
|
+
|
|
350
|
+
const FULL_UUID = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
|
|
351
|
+
|
|
352
|
+
function makeFakeCurrent(): {
|
|
353
|
+
current: AutocompleteProvider
|
|
354
|
+
getSuggestions: ReturnType<typeof vi.fn>
|
|
355
|
+
applyCompletion: ReturnType<typeof vi.fn>
|
|
356
|
+
} {
|
|
357
|
+
const getSuggestions = vi.fn()
|
|
358
|
+
const applyCompletion = vi.fn()
|
|
359
|
+
const current = {
|
|
360
|
+
getSuggestions,
|
|
361
|
+
applyCompletion,
|
|
362
|
+
} as unknown as AutocompleteProvider
|
|
363
|
+
return { current, getSuggestions, applyCompletion }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function makeOptions(): { signal: AbortSignal; force?: boolean } {
|
|
367
|
+
return { signal: new AbortController().signal }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
describe('createHashAutocompleteProvider - getSuggestions', () => {
|
|
371
|
+
let agentDir: string
|
|
372
|
+
let cwdSessionDir: string
|
|
373
|
+
|
|
374
|
+
beforeEach(async () => {
|
|
375
|
+
agentDir = await mkdtemp(join(tmpdir(), 'hash-provider-wrapper-'))
|
|
376
|
+
cwdSessionDir = join(agentDir, 'sessions', 'cwdA')
|
|
377
|
+
await mkdir(cwdSessionDir, { recursive: true })
|
|
378
|
+
})
|
|
379
|
+
afterEach(async () => {
|
|
380
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('signal.aborted → 早退返 null,不调 current、不做 IO', async () => {
|
|
384
|
+
const { current, getSuggestions } = makeFakeCurrent()
|
|
385
|
+
const provider = createHashAutocompleteProvider(() => cwdSessionDir, current)
|
|
386
|
+
const aborted = new AbortController()
|
|
387
|
+
aborted.abort()
|
|
388
|
+
const result = await provider.getSuggestions(['hello #e6c9'], 0, 11, {
|
|
389
|
+
signal: aborted.signal,
|
|
390
|
+
})
|
|
391
|
+
expect(result).toBeNull()
|
|
392
|
+
expect(getSuggestions).not.toHaveBeenCalled()
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('非 # 前缀 → 显式委托 current.getSuggestions(同参数透传)', async () => {
|
|
396
|
+
const { current, getSuggestions } = makeFakeCurrent()
|
|
397
|
+
getSuggestions.mockResolvedValue(null)
|
|
398
|
+
const provider = createHashAutocompleteProvider(() => cwdSessionDir, current)
|
|
399
|
+
const options = makeOptions()
|
|
400
|
+
const lines = ['@path/to/fi']
|
|
401
|
+
const result = await provider.getSuggestions(lines, 0, 12, options)
|
|
402
|
+
expect(result).toBeNull()
|
|
403
|
+
expect(getSuggestions).toHaveBeenCalledTimes(1)
|
|
404
|
+
expect(getSuggestions.mock.calls[0]).toEqual([lines, 0, 12, options])
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('# 前缀有匹配 → 返回 items + prefix=#fragment,不委托 current', async () => {
|
|
408
|
+
await makeSession(cwdSessionDir, {
|
|
409
|
+
fileName: 'a.jsonl',
|
|
410
|
+
id: FULL_UUID,
|
|
411
|
+
cwd: '/demo',
|
|
412
|
+
firstUserText: '修复登录 bug',
|
|
413
|
+
})
|
|
414
|
+
const { current, getSuggestions } = makeFakeCurrent()
|
|
415
|
+
const provider = createHashAutocompleteProvider(() => cwdSessionDir, current)
|
|
416
|
+
const result = await provider.getSuggestions(['hello #e6c9'], 0, 11, makeOptions())
|
|
417
|
+
expect(result).not.toBeNull()
|
|
418
|
+
expect(result!.prefix).toBe('#e6c9')
|
|
419
|
+
expect(result!.items).toHaveLength(1)
|
|
420
|
+
expect(result!.items[0].value).toBe(`#${FULL_UUID}`) // 完整 uuid insertText
|
|
421
|
+
expect(result!.items[0].label).toContain('修复登录 bug')
|
|
422
|
+
expect(getSuggestions).not.toHaveBeenCalled()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('# 前缀无匹配 → return null 且不委托 current(避免 current 把 #xxx 当路径返回文件建议)', async () => {
|
|
426
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: FULL_UUID, cwd: '/demo' })
|
|
427
|
+
const { current, getSuggestions } = makeFakeCurrent()
|
|
428
|
+
const provider = createHashAutocompleteProvider(() => cwdSessionDir, current)
|
|
429
|
+
const result = await provider.getSuggestions(['see #deadbeef'], 0, 13, makeOptions())
|
|
430
|
+
expect(result).toBeNull()
|
|
431
|
+
expect(getSuggestions).not.toHaveBeenCalled()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('空 cwdSessionDir → # 前缀返 null(不调 listAll 全盘分支,防卡死)', async () => {
|
|
435
|
+
const { current, getSuggestions } = makeFakeCurrent()
|
|
436
|
+
const provider = createHashAutocompleteProvider(() => '', current)
|
|
437
|
+
const result = await provider.getSuggestions(['#'], 0, 1, makeOptions())
|
|
438
|
+
expect(result).toBeNull()
|
|
439
|
+
expect(getSuggestions).not.toHaveBeenCalled()
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
describe('createHashAutocompleteProvider - applyCompletion', () => {
|
|
444
|
+
it('前缀替换手术:光标前保留 + item.value + 光标后保留,行尾自动补空格,cursor 前移', () => {
|
|
445
|
+
const { current, applyCompletion } = makeFakeCurrent()
|
|
446
|
+
const provider = createHashAutocompleteProvider(() => '', current)
|
|
447
|
+
const lines = ['hello #e6c9']
|
|
448
|
+
// 'hello ' = 6 字符,'#e6c9' = 5 字符 → cursor 在 11
|
|
449
|
+
const out = provider.applyCompletion(lines, 0, 11, { value: `#${FULL_UUID}`, label: 'x' }, '#e6c9')
|
|
450
|
+
// 行尾选中:补一个尾随空格,用户直接打字即产生间隔
|
|
451
|
+
expect(out.lines[0]).toBe(`hello #${FULL_UUID} `)
|
|
452
|
+
expect(out.cursorLine).toBe(0)
|
|
453
|
+
// cursor 停在补的空格之后
|
|
454
|
+
expect(out.cursorCol).toBe(6 + `#${FULL_UUID}`.length + 1)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('光标后有内容且已以空白开头:替换只影响 # 片段区间,不重复补空格', () => {
|
|
458
|
+
const { current } = makeFakeCurrent()
|
|
459
|
+
const provider = createHashAutocompleteProvider(() => '', current)
|
|
460
|
+
const lines = ['before #e6c9 after']
|
|
461
|
+
// 'before ' = 7,'#e6c9' = 5 → cursor 12,尾部 ' after' 保留
|
|
462
|
+
const out = provider.applyCompletion(lines, 0, 12, { value: `#${FULL_UUID}`, label: 'x' }, '#e6c9')
|
|
463
|
+
expect(out.lines[0]).toBe(`before #${FULL_UUID} after`)
|
|
464
|
+
expect(out.cursorCol).toBe(7 + `#${FULL_UUID}`.length)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('光标后紧贴非空白内容:补一个空格与后续内容隔开', () => {
|
|
468
|
+
const { current } = makeFakeCurrent()
|
|
469
|
+
const provider = createHashAutocompleteProvider(() => '', current)
|
|
470
|
+
const lines = ['before #e6c9after']
|
|
471
|
+
// 'before ' = 7,'#e6c9' = 5 → cursor 12,尾部 'after' 紧贴
|
|
472
|
+
const out = provider.applyCompletion(lines, 0, 12, { value: `#${FULL_UUID}`, label: 'x' }, '#e6c9')
|
|
473
|
+
expect(out.lines[0]).toBe(`before #${FULL_UUID} after`)
|
|
474
|
+
expect(out.cursorCol).toBe(7 + `#${FULL_UUID}`.length + 1)
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('CJK 多字节内容:按 JS 字符串长度(code unit)计算区间与 cursor,不切坏字符', () => {
|
|
478
|
+
const { current } = makeFakeCurrent()
|
|
479
|
+
const provider = createHashAutocompleteProvider(() => '', current)
|
|
480
|
+
const lines = ['你好 #e6c9']
|
|
481
|
+
// '你好 ' = 3 个 code unit,'#e6c9' = 5 → cursor 8
|
|
482
|
+
const out = provider.applyCompletion(lines, 0, 8, { value: `#${FULL_UUID}`, label: 'x' }, '#e6c9')
|
|
483
|
+
expect(out.lines[0]).toBe(`你好 #${FULL_UUID} `)
|
|
484
|
+
expect(out.cursorCol).toBe(3 + `#${FULL_UUID}`.length + 1)
|
|
485
|
+
// 替换后无孤立代理对(CJK 未被切半)
|
|
486
|
+
expect(out.lines[0].length).toBe(3 + `#${FULL_UUID}`.length + 1)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('非本 provider 的 item(value 不以 # 开头)→ 委托 current.applyCompletion', () => {
|
|
490
|
+
const { current, applyCompletion } = makeFakeCurrent()
|
|
491
|
+
const provider = createHashAutocompleteProvider(() => '', current)
|
|
492
|
+
const lines = ['@file.txt']
|
|
493
|
+
applyCompletion.mockReturnValue({ lines, cursorLine: 0, cursorCol: 5 })
|
|
494
|
+
const out = provider.applyCompletion(lines, 0, 5, { value: '/path/file.txt', label: 'file' }, '@file.txt')
|
|
495
|
+
expect(applyCompletion).toHaveBeenCalledTimes(1)
|
|
496
|
+
expect(out).toEqual({ lines, cursorLine: 0, cursorCol: 5 })
|
|
497
|
+
})
|
|
498
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { Check } from 'typebox/value'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* M3 pi 边界层(src/index.ts)单测(MF-6:此前该层零测试)。
|
|
7
|
+
*
|
|
8
|
+
* mock pi/ctx(as unknown as ExtensionAPI),测:
|
|
9
|
+
* 1. execute catch → isError:true 文本返回(「execute 不向 pi 抛」契约守护)
|
|
10
|
+
* 2. session_start handler 的 ctx.mode!=='tui' 守卫
|
|
11
|
+
* 3. registeredPis WeakSet 去重:同 pi 二次 session_start 不重复注册;不同 pi 可注册
|
|
12
|
+
* 4. typeof ctx.ui.addAutocompleteProvider 运行时守卫(ui 缺方法 → 跳过,不崩)
|
|
13
|
+
* 5. registerCommand + addAutocompleteProvider 组装(tui 模式下各注册一次)
|
|
14
|
+
* 6. TypeBox schema 与 SessionReadParams 对齐:合法 params 过、非法 action/scope 拒
|
|
15
|
+
*
|
|
16
|
+
* 不触碰真实文件系统:getAgentDir mock 固定假路径(execute 用例只触发 F5 抛错路径,
|
|
17
|
+
* 不读盘)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// vi.mock 在 import 前 hoist;SessionManager stub 仅防 session-command/hash-provider
|
|
21
|
+
// 模块加载期缺导出(本文件用例不调用它)
|
|
22
|
+
vi.mock('@earendil-works/pi-coding-agent', () => ({
|
|
23
|
+
getAgentDir: () => '/tmp/pi-session-reader-test-agent',
|
|
24
|
+
SessionManager: class {
|
|
25
|
+
static async listAll(): Promise<never[]> {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
import sessionReaderExtension from '../index.js'
|
|
32
|
+
|
|
33
|
+
interface FakePi {
|
|
34
|
+
pi: Record<string, unknown>
|
|
35
|
+
registerTool: ReturnType<typeof vi.fn>
|
|
36
|
+
registerCommand: ReturnType<typeof vi.fn>
|
|
37
|
+
on: ReturnType<typeof vi.fn>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeFakePi(): FakePi {
|
|
41
|
+
const registerTool = vi.fn()
|
|
42
|
+
const registerCommand = vi.fn()
|
|
43
|
+
const on = vi.fn()
|
|
44
|
+
return { pi: { registerTool, registerCommand, on }, registerTool, registerCommand, on }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface FakeCtx {
|
|
48
|
+
ctx: Record<string, unknown>
|
|
49
|
+
addAutocompleteProvider: ReturnType<typeof vi.fn>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeFakeCtx(opts: { mode?: string; withUi?: boolean; sessionDir?: string } = {}): FakeCtx {
|
|
53
|
+
const addAutocompleteProvider = vi.fn()
|
|
54
|
+
const ctx: Record<string, unknown> = {
|
|
55
|
+
mode: opts.mode ?? 'tui',
|
|
56
|
+
sessionManager: { getSessionDir: () => opts.sessionDir ?? '/tmp/fake-session-dir' },
|
|
57
|
+
}
|
|
58
|
+
if (opts.withUi ?? true) {
|
|
59
|
+
ctx.ui = { addAutocompleteProvider }
|
|
60
|
+
} else {
|
|
61
|
+
ctx.ui = {}
|
|
62
|
+
}
|
|
63
|
+
return { ctx, addAutocompleteProvider }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 触发 fake pi 的 session_start handler(取第一个注册的 handler)。 */
|
|
67
|
+
function fireSessionStart(on: ReturnType<typeof vi.fn>, ctx: Record<string, unknown>): void {
|
|
68
|
+
const handler = on.mock.calls.find((c) => c[0] === 'session_start')?.[1]
|
|
69
|
+
expect(handler).toBeTypeOf('function')
|
|
70
|
+
handler({ type: 'session_start' }, ctx)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('sessionReaderExtension - execute 契约', () => {
|
|
74
|
+
let fake: FakePi
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
fake = makeFakePi()
|
|
78
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('handler 抛错 → execute 返回 isError:true + 👉 文本,不向 pi 抛', async () => {
|
|
82
|
+
const toolDef = fake.registerTool.mock.calls[0][0] as {
|
|
83
|
+
name: string
|
|
84
|
+
execute: (
|
|
85
|
+
toolCallId: string,
|
|
86
|
+
params: unknown,
|
|
87
|
+
signal: AbortSignal | undefined,
|
|
88
|
+
onUpdate: unknown,
|
|
89
|
+
ctx: unknown,
|
|
90
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>
|
|
91
|
+
}
|
|
92
|
+
expect(toolDef.name).toBe('session_read')
|
|
93
|
+
// action=find 缺 query → F5 requireStr 抛错 → execute catch 转换
|
|
94
|
+
const result = await toolDef.execute('tc-1', { action: 'find' }, undefined, undefined, undefined)
|
|
95
|
+
expect(result.isError).toBe(true)
|
|
96
|
+
expect(result.content[0].type).toBe('text')
|
|
97
|
+
expect(result.content[0].text).toContain('👉')
|
|
98
|
+
// 非 Error 抛错(string)也能转文本
|
|
99
|
+
const result2 = await toolDef.execute('tc-2', { action: 123 }, undefined, undefined, undefined)
|
|
100
|
+
expect(result2.isError).toBe(true)
|
|
101
|
+
expect(typeof result2.content[0].text).toBe('string')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('sessionReaderExtension - session_start TUI 注册', () => {
|
|
106
|
+
it('mode !== tui → 不注册 command/provider', () => {
|
|
107
|
+
const fake = makeFakePi()
|
|
108
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
109
|
+
const { ctx } = makeFakeCtx({ mode: 'rpc', withUi: true })
|
|
110
|
+
fireSessionStart(fake.on, ctx)
|
|
111
|
+
expect(fake.registerCommand).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('tui 模式 → registerCommand(session-pick) + addAutocompleteProvider 各一次', () => {
|
|
115
|
+
const fake = makeFakePi()
|
|
116
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
117
|
+
const { ctx, addAutocompleteProvider } = makeFakeCtx({ mode: 'tui' })
|
|
118
|
+
fireSessionStart(fake.on, ctx)
|
|
119
|
+
expect(fake.registerCommand).toHaveBeenCalledTimes(1)
|
|
120
|
+
expect(fake.registerCommand.mock.calls[0][0]).toBe('session-pick')
|
|
121
|
+
expect(addAutocompleteProvider).toHaveBeenCalledTimes(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('同 pi 二次 session_start → 不重复注册(WeakSet 去重,防 provider 堆叠)', () => {
|
|
125
|
+
const fake = makeFakePi()
|
|
126
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
127
|
+
fireSessionStart(fake.on, makeFakeCtx().ctx)
|
|
128
|
+
fireSessionStart(fake.on, makeFakeCtx().ctx)
|
|
129
|
+
expect(fake.registerCommand).toHaveBeenCalledTimes(1)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('不同 pi 实例(resume 新 session)→ 各自可注册', () => {
|
|
133
|
+
const fake1 = makeFakePi()
|
|
134
|
+
const fake2 = makeFakePi()
|
|
135
|
+
sessionReaderExtension(fake1.pi as unknown as ExtensionAPI)
|
|
136
|
+
sessionReaderExtension(fake2.pi as unknown as ExtensionAPI)
|
|
137
|
+
fireSessionStart(fake1.on, makeFakeCtx().ctx)
|
|
138
|
+
fireSessionStart(fake2.on, makeFakeCtx().ctx)
|
|
139
|
+
expect(fake1.registerCommand).toHaveBeenCalledTimes(1)
|
|
140
|
+
expect(fake2.registerCommand).toHaveBeenCalledTimes(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('ctx.ui 无 addAutocompleteProvider(typeof 守卫)→ 跳过注册不崩', () => {
|
|
144
|
+
const fake = makeFakePi()
|
|
145
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
146
|
+
const { ctx } = makeFakeCtx({ withUi: false })
|
|
147
|
+
fireSessionStart(fake.on, ctx)
|
|
148
|
+
expect(fake.registerCommand).not.toHaveBeenCalled()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('sessionReaderExtension - TypeBox schema 与 SessionReadParams 对齐', () => {
|
|
153
|
+
it('合法完整 params 通过 schema 校验;非法 action/scope/类型被拒', () => {
|
|
154
|
+
const fake = makeFakePi()
|
|
155
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
156
|
+
const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
|
|
157
|
+
const schema = toolDef.parameters
|
|
158
|
+
|
|
159
|
+
// 各 action 合法形态
|
|
160
|
+
expect(Check(schema, { action: 'find', query: 'e6c96', limit: 5 })).toBe(true)
|
|
161
|
+
expect(
|
|
162
|
+
Check(schema, {
|
|
163
|
+
action: 'search',
|
|
164
|
+
session: '019e6c96',
|
|
165
|
+
pattern: 'plugin',
|
|
166
|
+
scope: 'toolResult',
|
|
167
|
+
}),
|
|
168
|
+
).toBe(true)
|
|
169
|
+
expect(Check(schema, { action: 'outline', session: 'e6c96', granularity: 'entry' })).toBe(true)
|
|
170
|
+
expect(Check(schema, { action: 'export', session: 'e6c96', format: 'family' })).toBe(true)
|
|
171
|
+
expect(
|
|
172
|
+
Check(schema, { action: 'extract', session: 'e6c96', what: 'tool-results', tool: 'bash' }),
|
|
173
|
+
).toBe(true)
|
|
174
|
+
// 全部可选字段齐全
|
|
175
|
+
expect(
|
|
176
|
+
Check(schema, {
|
|
177
|
+
action: 'detail',
|
|
178
|
+
session: 'e6c96',
|
|
179
|
+
turns: 'T001-T003',
|
|
180
|
+
includeToolResult: true,
|
|
181
|
+
includeThinking: false,
|
|
182
|
+
allBranches: true,
|
|
183
|
+
}),
|
|
184
|
+
).toBe(true)
|
|
185
|
+
|
|
186
|
+
// 非法值被拒(enum 约束生效)
|
|
187
|
+
expect(Check(schema, { action: 'bogus' })).toBe(false)
|
|
188
|
+
expect(Check(schema, { action: 'find', scope: 'nope' })).toBe(false)
|
|
189
|
+
expect(Check(schema, { action: 'extract', what: 'everything' })).toBe(false)
|
|
190
|
+
expect(Check(schema, { action: 42 })).toBe(false)
|
|
191
|
+
expect(Check(schema, {})).toBe(false) // 缺必填 action
|
|
192
|
+
})
|
|
193
|
+
})
|