@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,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } 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 { listMainSessions, listSubagentSessions } from '../discovery/roots.js'
|
|
6
|
+
import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR } from './real-data.js'
|
|
7
|
+
|
|
8
|
+
describe('listMainSessions', () => {
|
|
9
|
+
let dir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
dir = await mkdtemp(join(tmpdir(), 'roots-test-'))
|
|
13
|
+
})
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(dir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('扫描 sessions/<slug>/*.jsonl,排除 *.jsonl.finalized', async () => {
|
|
19
|
+
const slug = '--Users-foo--'
|
|
20
|
+
await mkdir(join(dir, 'sessions', slug), { recursive: true })
|
|
21
|
+
await writeFile(join(dir, 'sessions', slug, 'a.jsonl'), '{"type":"session","id":"a"}\n')
|
|
22
|
+
await writeFile(join(dir, 'sessions', slug, 'b.jsonl.finalized'), '{"type":"session","id":"b"}\n')
|
|
23
|
+
|
|
24
|
+
const result = await listMainSessions(dir)
|
|
25
|
+
expect(result).toHaveLength(1)
|
|
26
|
+
expect(result[0].path.endsWith('a.jsonl')).toBe(true)
|
|
27
|
+
expect(result[0].path.endsWith('.finalized')).toBe(false)
|
|
28
|
+
expect(result[0].mtime).toBeTypeOf('number')
|
|
29
|
+
expect(result[0].mtime).toBeGreaterThan(0)
|
|
30
|
+
expect(result[0].size).toBeTypeOf('number')
|
|
31
|
+
expect(result[0].size).toBeGreaterThan(0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('嵌套子目录正确递归(sessions/<slug>/deep/nested/d.jsonl)', async () => {
|
|
35
|
+
await mkdir(join(dir, 'sessions', 'slug', 'deep', 'nested'), { recursive: true })
|
|
36
|
+
await writeFile(join(dir, 'sessions', 'slug', 'deep', 'nested', 'd.jsonl'), '{}\n')
|
|
37
|
+
// 同 slug 直接层也放一个,验证同一 slug 下平铺与嵌套并存
|
|
38
|
+
await writeFile(join(dir, 'sessions', 'slug', 'top.jsonl'), '{}\n')
|
|
39
|
+
|
|
40
|
+
const result = await listMainSessions(dir)
|
|
41
|
+
const names = result.map((m) => m.path.split('/').pop()!)
|
|
42
|
+
expect(names).toContain('d.jsonl')
|
|
43
|
+
expect(names).toContain('top.jsonl')
|
|
44
|
+
expect(result).toHaveLength(2)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('跳过 workflow-state 子目录(wf-*.jsonl 非 session 文件)', async () => {
|
|
48
|
+
const slug = '--Users-x--'
|
|
49
|
+
await mkdir(join(dir, 'sessions', slug, 'workflow-state'), { recursive: true })
|
|
50
|
+
await writeFile(join(dir, 'sessions', slug, 'real.jsonl'), '{"type":"session"}\n')
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(dir, 'sessions', slug, 'workflow-state', 'wf-abc.jsonl'),
|
|
53
|
+
'{"v":"wf-run-v1"}\n',
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const result = await listMainSessions(dir)
|
|
57
|
+
expect(result).toHaveLength(1)
|
|
58
|
+
expect(result[0].path.endsWith('real.jsonl')).toBe(true)
|
|
59
|
+
expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('空 agentDir(无 sessions 目录)返回 [],不抛错', async () => {
|
|
63
|
+
await expect(listMainSessions(dir)).resolves.toEqual([])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('不存在的 agentDir 返回 [],不抛错', async () => {
|
|
67
|
+
await expect(listMainSessions(join(dir, 'no-such-dir'))).resolves.toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it.skipIf(!HAS_E6)('真实数据:扫描 ~/.pi/agent,含 019e6c96,不含 .finalized 与 wf-', async () => {
|
|
71
|
+
const result = await listMainSessions(REAL_AGENT_DIR)
|
|
72
|
+
expect(result.length).toBeGreaterThan(0)
|
|
73
|
+
// 含目标 session
|
|
74
|
+
expect(result.some((m) => m.path.includes('019e6c96'))).toBe(true)
|
|
75
|
+
// 排除 finalized
|
|
76
|
+
expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
|
|
77
|
+
// 排除 workflow-state 目录
|
|
78
|
+
expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
|
|
79
|
+
// 排除 wf- 前缀文件名
|
|
80
|
+
expect(result.every((m) => !m.path.split('/').pop()!.startsWith('wf-'))).toBe(true)
|
|
81
|
+
// mtime/size 真实
|
|
82
|
+
expect(result.every((m) => m.mtime > 0 && m.size > 0)).toBe(true)
|
|
83
|
+
}, 30000)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('listSubagentSessions', () => {
|
|
87
|
+
let dir: string
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
dir = await mkdtemp(join(tmpdir(), 'roots-sub-test-'))
|
|
91
|
+
})
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
await rm(dir, { recursive: true, force: true })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('扫描 subagents/<slug>/sessions/*.jsonl,排除 .finalized', async () => {
|
|
97
|
+
const slug = '--Users-foo--'
|
|
98
|
+
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
99
|
+
await writeFile(join(dir, 'subagents', slug, 'sessions', 'c.jsonl'), '{"type":"session"}\n')
|
|
100
|
+
await writeFile(
|
|
101
|
+
join(dir, 'subagents', slug, 'sessions', 'c.jsonl.finalized'),
|
|
102
|
+
'{"type":"session"}\n',
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const result = await listSubagentSessions(dir)
|
|
106
|
+
expect(result).toHaveLength(1)
|
|
107
|
+
expect(result[0].path.endsWith('c.jsonl')).toBe(true)
|
|
108
|
+
expect(result[0].path.endsWith('.finalized')).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('records/ 子目录(.json manifest)不被误收', async () => {
|
|
112
|
+
const slug = '--Users-foo--'
|
|
113
|
+
await mkdir(join(dir, 'subagents', slug, 'records'), { recursive: true })
|
|
114
|
+
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
115
|
+
await writeFile(join(dir, 'subagents', slug, 'records', 'manifest.json'), '{}\n')
|
|
116
|
+
await writeFile(join(dir, 'subagents', slug, 'sessions', 'sub.jsonl'), '{"type":"session"}\n')
|
|
117
|
+
|
|
118
|
+
const result = await listSubagentSessions(dir)
|
|
119
|
+
expect(result).toHaveLength(1)
|
|
120
|
+
expect(result[0].path.endsWith('sub.jsonl')).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('无 subagents 目录返回 [],不抛错', async () => {
|
|
124
|
+
await expect(listSubagentSessions(dir)).resolves.toEqual([])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:扫描 ~/.pi/agent/subagents 返回非空', async () => {
|
|
128
|
+
const result = await listSubagentSessions(REAL_AGENT_DIR)
|
|
129
|
+
expect(result.length).toBeGreaterThan(0)
|
|
130
|
+
expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
|
|
131
|
+
}, 30000)
|
|
132
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { createSessionCommand } from '../tui/session-command.js'
|
|
6
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* /session-pick 命令(src/tui/session-command.ts)单测(MF-7:此前零测试)。
|
|
10
|
+
*
|
|
11
|
+
* 真实 tmpdir 造 session 文件(SessionManager.listAll 真跑不 mock,同 hash-provider 惯例),
|
|
12
|
+
* fake ctx(select/notify/setEditorText vi.fn)。覆盖:
|
|
13
|
+
* - getArgumentCompletions:listAll + uuid 过滤 + 空返 null
|
|
14
|
+
* - handler select 流程:选中插入 `#完整 uuid`、取消不插入、零匹配 notify warning
|
|
15
|
+
* - MF-2 回归:同 cwd 同预览同 age 桶(label 原样重复)→ 短 uuid 后缀消歧,选中第 2 条插第 2 条 uuid
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
async function makeSession(
|
|
19
|
+
dir: string,
|
|
20
|
+
opts: {
|
|
21
|
+
fileName: string
|
|
22
|
+
id: string
|
|
23
|
+
cwd?: string
|
|
24
|
+
name?: string
|
|
25
|
+
firstUserText?: string
|
|
26
|
+
},
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const header: Record<string, unknown> = {
|
|
29
|
+
type: 'session',
|
|
30
|
+
version: 3,
|
|
31
|
+
id: opts.id,
|
|
32
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
33
|
+
}
|
|
34
|
+
if (opts.cwd) header.cwd = opts.cwd
|
|
35
|
+
const lines: unknown[] = [header]
|
|
36
|
+
if (opts.name) {
|
|
37
|
+
lines.push({ type: 'session_info', id: opts.id + '-info', name: opts.name })
|
|
38
|
+
}
|
|
39
|
+
if (opts.firstUserText) {
|
|
40
|
+
lines.push({
|
|
41
|
+
type: 'message',
|
|
42
|
+
id: opts.id + '-m1',
|
|
43
|
+
timestamp: '2026-01-01T01:00:00.000Z',
|
|
44
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
await mkdir(dir, { recursive: true })
|
|
48
|
+
await writeFile(join(dir, opts.fileName), lines.map((o) => JSON.stringify(o)).join('\n') + '\n')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** fake ExtensionCommandContext(仅 handler 用到的 ui 三件套)。 */
|
|
52
|
+
function makeFakeCtx(): {
|
|
53
|
+
ctx: ExtensionCommandContext
|
|
54
|
+
select: ReturnType<typeof vi.fn>
|
|
55
|
+
notify: ReturnType<typeof vi.fn>
|
|
56
|
+
setEditorText: ReturnType<typeof vi.fn>
|
|
57
|
+
} {
|
|
58
|
+
const select = vi.fn()
|
|
59
|
+
const notify = vi.fn()
|
|
60
|
+
const setEditorText = vi.fn()
|
|
61
|
+
const ctx = { ui: { select, notify, setEditorText } } as unknown as ExtensionCommandContext
|
|
62
|
+
return { ctx, select, notify, setEditorText }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('createSessionCommand - getArgumentCompletions', () => {
|
|
66
|
+
let agentDir: string
|
|
67
|
+
let cwdSessionDir: string
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
agentDir = await mkdtemp(join(tmpdir(), 'session-command-test-'))
|
|
71
|
+
cwdSessionDir = join(agentDir, 'sessions', 'cwdA')
|
|
72
|
+
await mkdir(cwdSessionDir, { recursive: true })
|
|
73
|
+
})
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('空参数 → recent 列表(value=完整 uuid 剥 #,label 含预览)', async () => {
|
|
79
|
+
await makeSession(cwdSessionDir, {
|
|
80
|
+
fileName: 'a.jsonl',
|
|
81
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
82
|
+
cwd: '/demo',
|
|
83
|
+
firstUserText: '修复登录 bug',
|
|
84
|
+
})
|
|
85
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
86
|
+
const items = await cmd.getArgumentCompletions('')
|
|
87
|
+
expect(items).not.toBeNull()
|
|
88
|
+
expect(items!.length).toBe(1)
|
|
89
|
+
expect(items![0].value).toBe('019e6c96-0a0c-74b8-a73f-d1854d88e2a7') // 完整 uuid,无 #
|
|
90
|
+
expect(items![0].label).toContain('修复登录 bug')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('uuid 片段过滤(与 # 弹窗一致)', async () => {
|
|
94
|
+
await makeSession(cwdSessionDir, {
|
|
95
|
+
fileName: 'a.jsonl',
|
|
96
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
97
|
+
cwd: '/demo',
|
|
98
|
+
})
|
|
99
|
+
await makeSession(cwdSessionDir, {
|
|
100
|
+
fileName: 'b.jsonl',
|
|
101
|
+
id: '019fffff-1111-2222-3333-444455556666',
|
|
102
|
+
cwd: '/demo',
|
|
103
|
+
})
|
|
104
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
105
|
+
const items = await cmd.getArgumentCompletions('e6c9')
|
|
106
|
+
expect(items).not.toBeNull()
|
|
107
|
+
expect(items!.length).toBe(1)
|
|
108
|
+
expect(items![0].value).toBe('019e6c96-0a0c-74b8-a73f-d1854d88e2a7')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('无匹配 → null', async () => {
|
|
112
|
+
await makeSession(cwdSessionDir, {
|
|
113
|
+
fileName: 'a.jsonl',
|
|
114
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
115
|
+
cwd: '/demo',
|
|
116
|
+
})
|
|
117
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
118
|
+
expect(await cmd.getArgumentCompletions('deadbeef')).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('createSessionCommand - handler select 流程', () => {
|
|
123
|
+
let agentDir: string
|
|
124
|
+
let cwdSessionDir: string
|
|
125
|
+
|
|
126
|
+
beforeEach(async () => {
|
|
127
|
+
agentDir = await mkdtemp(join(tmpdir(), 'session-command-handler-'))
|
|
128
|
+
cwdSessionDir = join(agentDir, 'sessions', 'cwdA')
|
|
129
|
+
await mkdir(cwdSessionDir, { recursive: true })
|
|
130
|
+
})
|
|
131
|
+
afterEach(async () => {
|
|
132
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('选中 label → setEditorText(#完整 uuid + 尾随空格)(value 语义保持完整 uuid)', async () => {
|
|
136
|
+
await makeSession(cwdSessionDir, {
|
|
137
|
+
fileName: 'a.jsonl',
|
|
138
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
139
|
+
cwd: '/demo',
|
|
140
|
+
firstUserText: '修复登录 bug',
|
|
141
|
+
})
|
|
142
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
143
|
+
const { ctx, select, setEditorText } = makeFakeCtx()
|
|
144
|
+
select.mockImplementation(async (_title: string, options: string[]) => options[0])
|
|
145
|
+
await cmd.handler('', ctx)
|
|
146
|
+
expect(select).toHaveBeenCalledTimes(1)
|
|
147
|
+
expect(setEditorText).toHaveBeenCalledWith('#019e6c96-0a0c-74b8-a73f-d1854d88e2a7 ')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('取消(select 返回 undefined)→ 不插入', async () => {
|
|
151
|
+
await makeSession(cwdSessionDir, {
|
|
152
|
+
fileName: 'a.jsonl',
|
|
153
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
154
|
+
cwd: '/demo',
|
|
155
|
+
})
|
|
156
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
157
|
+
const { ctx, select, setEditorText } = makeFakeCtx()
|
|
158
|
+
select.mockResolvedValue(undefined)
|
|
159
|
+
await cmd.handler('', ctx)
|
|
160
|
+
expect(select).toHaveBeenCalledTimes(1)
|
|
161
|
+
expect(setEditorText).not.toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('零匹配 → notify warning,不调 select', async () => {
|
|
165
|
+
await makeSession(cwdSessionDir, {
|
|
166
|
+
fileName: 'a.jsonl',
|
|
167
|
+
id: '019e6c96-0a0c-74b8-a73f-d1854d88e2a7',
|
|
168
|
+
cwd: '/demo',
|
|
169
|
+
})
|
|
170
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
171
|
+
const { ctx, select, notify, setEditorText } = makeFakeCtx()
|
|
172
|
+
await cmd.handler('deadbeef', ctx)
|
|
173
|
+
expect(notify).toHaveBeenCalledWith('未找到匹配的 session。', 'warning')
|
|
174
|
+
expect(select).not.toHaveBeenCalled()
|
|
175
|
+
expect(setEditorText).not.toHaveBeenCalled()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('MF-2 回归:同 cwd 同预览同 age 桶 → label 带短 uuid 后缀消歧,选第 2 条插第 2 条 uuid', async () => {
|
|
179
|
+
// 两 session:相同首消息 + 相同 timestamp(同 age 桶)→ toCandidate label 完全相同
|
|
180
|
+
const ID1 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
|
|
181
|
+
const ID2 = '019fffff-1111-2222-3333-444455556666'
|
|
182
|
+
await makeSession(cwdSessionDir, { fileName: 'a.jsonl', id: ID1, cwd: '/demo', firstUserText: '相同的首条消息' })
|
|
183
|
+
await makeSession(cwdSessionDir, { fileName: 'b.jsonl', id: ID2, cwd: '/demo', firstUserText: '相同的首条消息' })
|
|
184
|
+
|
|
185
|
+
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
186
|
+
const { ctx, select, setEditorText } = makeFakeCtx()
|
|
187
|
+
// 用户选第 2 条(label 数组下标 1)
|
|
188
|
+
select.mockImplementation(async (_title: string, options: string[]) => options[1])
|
|
189
|
+
await cmd.handler('', ctx)
|
|
190
|
+
|
|
191
|
+
// select 收到的 labels 必须两两不同(uuid 后缀消歧生效)
|
|
192
|
+
const labelsArg = select.mock.calls[0][1] as string[]
|
|
193
|
+
expect(labelsArg.length).toBe(2)
|
|
194
|
+
expect(new Set(labelsArg).size).toBe(2)
|
|
195
|
+
// 插入的是第 2 条 session 的完整 uuid(旧实现 indexOf(label) 会错插第 1 条);尾随空格与 applyCompletion spacer 语义一致(S-2)
|
|
196
|
+
expect(setEditorText).toHaveBeenCalledWith(`#${ID2} `)
|
|
197
|
+
})
|
|
198
|
+
})
|