@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.
@@ -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
+ })