@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
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './src/index.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"pi": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./index.ts"
|
|
9
|
+
],
|
|
10
|
+
"skills": []
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package"
|
|
14
|
+
],
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"vitest": "^4.1.8"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.ts",
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"vitest.config.ts"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
+
"@earendil-works/pi-ai": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*",
|
|
27
|
+
"typebox": "*"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"@earendil-works/pi-ai": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"@earendil-works/pi-tui": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"typebox": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildFamilyIndex, resolveFamily } from '../core/family.js'
|
|
3
|
+
import type { Entry } from '../core/parser.js'
|
|
4
|
+
|
|
5
|
+
// ---- fixture 常量(映射 design §3.3 D-7 Q1 真实场景)----
|
|
6
|
+
const ROOT = '019fe620' // 家族根(无 parentSession)
|
|
7
|
+
const FORK = '019fe632' // fork 子代(parentSession 指向 ROOT 的文件)
|
|
8
|
+
const SUB = '019fe635' // subagent(rootSessionId=FORK,挂在 fork 子代下,非家族根)
|
|
9
|
+
|
|
10
|
+
// ---- fixture helpers ----
|
|
11
|
+
|
|
12
|
+
/** 构造 session header entry(type=session,含 id/cwd,可选 parentSession) */
|
|
13
|
+
function header(id: string, opts?: { parentSession?: string; cwd?: string }): Entry {
|
|
14
|
+
const e: Entry = { type: 'session', id, parentId: null, cwd: opts?.cwd ?? '/proj' }
|
|
15
|
+
if (opts?.parentSession) e.parentSession = opts.parentSession
|
|
16
|
+
return e
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 模拟真实文件路径:含 sessionId 的 jsonl 路径(resolveParentSessionId 靠 includes 反查) */
|
|
20
|
+
function sessionFile(id: string): string {
|
|
21
|
+
return `/sessions/2026-08-09T10-45-20Z_${id}.jsonl`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 构造 subagent-identity custom entry(data.rootSessionId/slug) */
|
|
25
|
+
function subagentIdentity(id: string, rootSessionId: string, slug: string): Entry {
|
|
26
|
+
return {
|
|
27
|
+
type: 'custom',
|
|
28
|
+
id,
|
|
29
|
+
parentId: null,
|
|
30
|
+
customType: 'subagent-identity',
|
|
31
|
+
data: { rootSessionId, slug },
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 构造 fileStats Map(M1 key=sessionId) */
|
|
36
|
+
function makeStats(
|
|
37
|
+
keys: string[],
|
|
38
|
+
opts: { mtime?: number; size?: number } = {},
|
|
39
|
+
): Map<string, { mtime: number; size: number }> {
|
|
40
|
+
const { mtime = 1000, size = 5000 } = opts
|
|
41
|
+
const m = new Map<string, { mtime: number; size: number }>()
|
|
42
|
+
for (const k of keys) m.set(k, { mtime, size })
|
|
43
|
+
return m
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Q1 核心 fixture:ROOT ← fork ← FORK;subagent SUB 挂在 fork 子代 FORK 下 */
|
|
47
|
+
function q1Fixture(aliveKeys: string[] = [ROOT, FORK, SUB]) {
|
|
48
|
+
return {
|
|
49
|
+
headers: [header(ROOT), header(FORK, { parentSession: sessionFile(ROOT) })],
|
|
50
|
+
identities: [subagentIdentity(SUB, FORK, 'deep-survey')],
|
|
51
|
+
fileStats: makeStats(aliveKeys),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('buildFamilyIndex', () => {
|
|
56
|
+
it('byId / childrenOf / subagentsByRoot 结构正确', () => {
|
|
57
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
58
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
59
|
+
|
|
60
|
+
expect(index.byId.has(ROOT)).toBe(true)
|
|
61
|
+
expect(index.byId.has(FORK)).toBe(true)
|
|
62
|
+
expect(index.byId.get(ROOT)?.cwd).toBe('/proj')
|
|
63
|
+
|
|
64
|
+
// childrenOf key 是父 sessionId(经 parentSession 文件路径反查)
|
|
65
|
+
expect(index.childrenOf.get(ROOT)).toHaveLength(1)
|
|
66
|
+
expect(index.childrenOf.get(ROOT)?.[0].sessionId).toBe(FORK)
|
|
67
|
+
|
|
68
|
+
// subagentsByRoot key 是 rootSessionId
|
|
69
|
+
expect(index.subagentsByRoot.get(FORK)).toHaveLength(1)
|
|
70
|
+
expect(index.subagentsByRoot.get(FORK)?.[0].sessionId).toBe(SUB)
|
|
71
|
+
expect(index.subagentsByRoot.get(FORK)?.[0].slug).toBe('deep-survey')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('parentSession 是文件路径:经 sessionId 子串反查建 childrenOf', () => {
|
|
75
|
+
const headers = [
|
|
76
|
+
header('aaa'),
|
|
77
|
+
header('bbb', { parentSession: '/some/dir/2026-01-01T00-00-00Z_aaa.jsonl' }),
|
|
78
|
+
]
|
|
79
|
+
const index = buildFamilyIndex(headers, [], new Map())
|
|
80
|
+
|
|
81
|
+
expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('parentSession 直接是 sessionId 也兼容(简化 fixture)', () => {
|
|
85
|
+
const headers = [header('aaa'), header('bbb', { parentSession: 'aaa' })]
|
|
86
|
+
const index = buildFamilyIndex(headers, [], new Map())
|
|
87
|
+
|
|
88
|
+
expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('parentSession 反查不到父 → 该 entry 不进 childrenOf(不报错)', () => {
|
|
92
|
+
const headers = [header('bbb', { parentSession: '/missing.jsonl' })]
|
|
93
|
+
const index = buildFamilyIndex(headers, [], new Map())
|
|
94
|
+
|
|
95
|
+
expect(index.childrenOf.size).toBe(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('identity 缺 rootSessionId/slug → 跳过(坏数据容错)', () => {
|
|
99
|
+
const bad: Entry = {
|
|
100
|
+
type: 'custom',
|
|
101
|
+
id: 'x',
|
|
102
|
+
parentId: null,
|
|
103
|
+
customType: 'subagent-identity',
|
|
104
|
+
data: { slug: 'no-root' },
|
|
105
|
+
}
|
|
106
|
+
const index = buildFamilyIndex([], [bad], new Map())
|
|
107
|
+
|
|
108
|
+
expect(index.subagentsByRoot.size).toBe(0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('fileStats 原样存入 index(供 cleanedUp 判断用)', () => {
|
|
112
|
+
const fileStats = makeStats(['s1'])
|
|
113
|
+
const index = buildFamilyIndex([], [], fileStats)
|
|
114
|
+
|
|
115
|
+
expect(index.fileStats).toBe(fileStats)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('resolveFamily - 隔代关联(design §3.3 D-7 Q1 核心断言)', () => {
|
|
120
|
+
it('从家族根 resolve 能关联到挂在 fork 子代下的隔代 subagent', () => {
|
|
121
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
122
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
123
|
+
|
|
124
|
+
const family = resolveFamily(ROOT, index)
|
|
125
|
+
|
|
126
|
+
// Q1 核心:subagent SUB 的 rootSessionId 是 fork 子代 FORK,不是 root ROOT。
|
|
127
|
+
// 只查 root 的 subagentsByRoot 会漏;必须对 fork 链每个节点(含 forks)查。
|
|
128
|
+
expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
|
|
129
|
+
expect(family.subagents.find((s) => s.sessionId === SUB)?.slug).toBe('deep-survey')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('从 fork 子代 resolve 也能关联到直接挂在其下的 subagent', () => {
|
|
133
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
134
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
135
|
+
|
|
136
|
+
const family = resolveFamily(FORK, index)
|
|
137
|
+
expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('resolveFamily - fork 链', () => {
|
|
142
|
+
it('fork 子代的 parents 含 root;root 的 forks 含 fork 子代', () => {
|
|
143
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
144
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
145
|
+
|
|
146
|
+
expect(resolveFamily(FORK, index).parents.some((p) => p.sessionId === ROOT)).toBe(true)
|
|
147
|
+
expect(resolveFamily(ROOT, index).forks.some((f) => f.sessionId === FORK)).toBe(true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('root 无 parentSession → parents 空、forks 取 childrenOf', () => {
|
|
151
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
152
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
153
|
+
|
|
154
|
+
const family = resolveFamily(ROOT, index)
|
|
155
|
+
expect(family.parents).toEqual([])
|
|
156
|
+
expect(family.forks.map((f) => f.sessionId)).toEqual([FORK])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('线性多级 fork 链:C←B←A,resolveFamily(C).parents=[B,A]', () => {
|
|
160
|
+
const headers = [
|
|
161
|
+
header('A'),
|
|
162
|
+
header('B', { parentSession: sessionFile('A') }),
|
|
163
|
+
header('C', { parentSession: sessionFile('B') }),
|
|
164
|
+
]
|
|
165
|
+
const index = buildFamilyIndex(headers, [], new Map())
|
|
166
|
+
|
|
167
|
+
const family = resolveFamily('C', index)
|
|
168
|
+
expect(family.parents.map((p) => p.sessionId)).toEqual(['B', 'A'])
|
|
169
|
+
// forks 只含直接子代(孙代 C 是 B 的子代,不是 A 的直接子代)
|
|
170
|
+
expect(resolveFamily('A', index).forks.map((f) => f.sessionId)).toEqual(['B'])
|
|
171
|
+
expect(resolveFamily('B', index).forks.map((f) => f.sessionId)).toEqual(['C'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('workflows 恒为空数组(M1,接口字段保留供 M2)', () => {
|
|
175
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
176
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
177
|
+
|
|
178
|
+
expect(resolveFamily(ROOT, index).workflows).toEqual([])
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('resolveFamily - cleanedUp(subagent 文件被 GC)', () => {
|
|
183
|
+
it('identity 在但 fileStats 不含其 sessionId → cleanedUp=true', () => {
|
|
184
|
+
const { headers, identities, fileStats } = q1Fixture([ROOT, FORK]) // SUB 不在 fileStats(已 GC)
|
|
185
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
186
|
+
|
|
187
|
+
const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
|
|
188
|
+
expect(sub?.cleanedUp).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('identity 在且 fileStats 含其 sessionId → cleanedUp=false', () => {
|
|
192
|
+
const { headers, identities, fileStats } = q1Fixture() // 默认 [ROOT,FORK,SUB],SUB 存活
|
|
193
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
194
|
+
|
|
195
|
+
const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
|
|
196
|
+
expect(sub?.cleanedUp).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('resolveFamily - 错误', () => {
|
|
201
|
+
it('sessionId 不在 index → 抛 Error', () => {
|
|
202
|
+
const { headers, identities, fileStats } = q1Fixture()
|
|
203
|
+
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
204
|
+
|
|
205
|
+
expect(() => resolveFamily('unknown-session', index)).toThrow(/not found in family index/)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { findSessions } from '../discovery/find.js'
|
|
6
|
+
import { REAL_AGENT_DIR, HAS_E6 } from './real-data.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 建一个假 session 文件:首行 header(type=session,含 id/cwd/parentSession),
|
|
10
|
+
* 可选第二条 user message(用于名称关键词匹配 + firstMessagePreview)。返回绝对路径。
|
|
11
|
+
*/
|
|
12
|
+
async function makeSession(
|
|
13
|
+
dir: string,
|
|
14
|
+
opts: {
|
|
15
|
+
name: string
|
|
16
|
+
id: string
|
|
17
|
+
cwd?: string
|
|
18
|
+
parentSession?: string
|
|
19
|
+
firstUserText?: string
|
|
20
|
+
},
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const header: Record<string, unknown> = {
|
|
23
|
+
type: 'session',
|
|
24
|
+
id: opts.id,
|
|
25
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
26
|
+
}
|
|
27
|
+
if (opts.cwd) header.cwd = opts.cwd
|
|
28
|
+
if (opts.parentSession) header.parentSession = opts.parentSession
|
|
29
|
+
const lines = [JSON.stringify(header)]
|
|
30
|
+
if (opts.firstUserText) {
|
|
31
|
+
lines.push(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
type: 'message',
|
|
34
|
+
id: opts.id + '-m1',
|
|
35
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
await mkdir(dir, { recursive: true })
|
|
40
|
+
const path = join(dir, opts.name)
|
|
41
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
42
|
+
return path
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('findSessions', () => {
|
|
46
|
+
let agentDir: string
|
|
47
|
+
let slugDir: string
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
agentDir = await mkdtemp(join(tmpdir(), 'find-test-'))
|
|
51
|
+
slugDir = join(agentDir, 'sessions', '--Users-demo--')
|
|
52
|
+
})
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('uuid 片段匹配正确的 session(sessionId 含 query)', async () => {
|
|
58
|
+
await makeSession(slugDir, { name: 'a.jsonl', id: '019e6c96-aaaa-bbbb', cwd: '/demo' })
|
|
59
|
+
await makeSession(slugDir, { name: 'b.jsonl', id: '019fffff-cccc-dddd', cwd: '/demo' })
|
|
60
|
+
|
|
61
|
+
const { matches } = await findSessions('e6c96', agentDir)
|
|
62
|
+
expect(matches).toHaveLength(1)
|
|
63
|
+
expect(matches[0].sessionId).toBe('019e6c96-aaaa-bbbb')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('uuid 片段匹配:文件路径含 query(query 出现在文件名)', async () => {
|
|
67
|
+
// sessionId 不含 query,但文件名含 → path.includes(query) 命中
|
|
68
|
+
await makeSession(slugDir, { name: '2026-special-name.jsonl', id: 'sid-no-query', cwd: '/demo' })
|
|
69
|
+
const { matches } = await findSessions('special', agentDir)
|
|
70
|
+
expect(matches).toHaveLength(1)
|
|
71
|
+
expect(matches[0].sessionId).toBe('sid-no-query')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('fileName 填完整绝对路径,mtime/sizeBytes/cwd 为真实值', async () => {
|
|
75
|
+
const path = await makeSession(slugDir, { name: 'a.jsonl', id: 'sid-fullpath', cwd: '/demo' })
|
|
76
|
+
const { matches } = await findSessions('sid-fullpath', agentDir)
|
|
77
|
+
expect(matches).toHaveLength(1)
|
|
78
|
+
expect(matches[0].fileName).toBe(path)
|
|
79
|
+
expect(matches[0].mtime).toBeGreaterThan(0)
|
|
80
|
+
expect(matches[0].sizeBytes).toBeGreaterThan(0)
|
|
81
|
+
expect(matches[0].cwd).toBe('/demo')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('cwd 过滤:只留 header.cwd === opts.cwd 的', async () => {
|
|
85
|
+
await makeSession(slugDir, { name: 'a.jsonl', id: 'aaa-shared', cwd: '/proj-a' })
|
|
86
|
+
await makeSession(slugDir, { name: 'b.jsonl', id: 'bbb-shared', cwd: '/proj-b' })
|
|
87
|
+
|
|
88
|
+
// query 'shared' 同时命中两个 id,cwd 过滤后只留 /proj-a
|
|
89
|
+
const { matches } = await findSessions('shared', agentDir, { cwd: '/proj-a' })
|
|
90
|
+
expect(matches).toHaveLength(1)
|
|
91
|
+
expect(matches[0].sessionId).toBe('aaa-shared')
|
|
92
|
+
expect(matches[0].cwd).toBe('/proj-a')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('limit 截断:truncated 标记正确', async () => {
|
|
96
|
+
// 5 个文件 id 都含 "common",uuid 片段 "common" 全匹配
|
|
97
|
+
for (let i = 0; i < 5; i++) {
|
|
98
|
+
await makeSession(slugDir, { name: `f${i}.jsonl`, id: `common-${i}`, cwd: '/demo' })
|
|
99
|
+
}
|
|
100
|
+
const { matches, truncated } = await findSessions('common', agentDir, { limit: 2 })
|
|
101
|
+
expect(matches).toHaveLength(2)
|
|
102
|
+
expect(truncated).toBe(true)
|
|
103
|
+
|
|
104
|
+
// limit >= 总数时 truncated=false
|
|
105
|
+
const all = await findSessions('common', agentDir, { limit: 10 })
|
|
106
|
+
expect(all.matches).toHaveLength(5)
|
|
107
|
+
expect(all.truncated).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("query='recent' 按 mtime 倒序", async () => {
|
|
111
|
+
const paths: string[] = []
|
|
112
|
+
for (let i = 0; i < 3; i++) {
|
|
113
|
+
paths.push(await makeSession(slugDir, { name: `r${i}.jsonl`, id: `recent-${i}`, cwd: '/demo' }))
|
|
114
|
+
}
|
|
115
|
+
// 设递增 mtime:r0 最旧,r2 最新
|
|
116
|
+
const base = Math.floor(Date.now() / 1000)
|
|
117
|
+
await utimes(paths[0], base, base)
|
|
118
|
+
await utimes(paths[1], base + 100, base + 100)
|
|
119
|
+
await utimes(paths[2], base + 200, base + 200)
|
|
120
|
+
|
|
121
|
+
const { matches } = await findSessions('recent', agentDir)
|
|
122
|
+
expect(matches).toHaveLength(3)
|
|
123
|
+
expect(matches[0].sessionId).toBe('recent-2') // mtime 最大排前
|
|
124
|
+
expect(matches[1].sessionId).toBe('recent-1')
|
|
125
|
+
expect(matches[2].sessionId).toBe('recent-0')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("recent 也尊重 cwd 过滤", async () => {
|
|
129
|
+
await makeSession(slugDir, { name: 'a.jsonl', id: 'r-a', cwd: '/proj-a' })
|
|
130
|
+
await makeSession(slugDir, { name: 'b.jsonl', id: 'r-b', cwd: '/proj-b' })
|
|
131
|
+
|
|
132
|
+
const { matches } = await findSessions('recent', agentDir, { cwd: '/proj-a' })
|
|
133
|
+
expect(matches).toHaveLength(1)
|
|
134
|
+
expect(matches[0].sessionId).toBe('r-a')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('名称关键词匹配:uuid 无匹配时读首消息预览含 query', async () => {
|
|
138
|
+
// query 'plugin' 含 p/l/u/g/i/n(p/l/u 非十六进制)→ 非 uuid 特征,走首消息 fallback
|
|
139
|
+
await makeSession(slugDir, {
|
|
140
|
+
name: 'a.jsonl',
|
|
141
|
+
id: 'kw-aaaa',
|
|
142
|
+
cwd: '/demo',
|
|
143
|
+
firstUserText: '重构插件架构 plugin architecture review',
|
|
144
|
+
})
|
|
145
|
+
await makeSession(slugDir, {
|
|
146
|
+
name: 'b.jsonl',
|
|
147
|
+
id: 'kw-bbbb',
|
|
148
|
+
cwd: '/demo',
|
|
149
|
+
firstUserText: '完全无关的内容 unrelated content',
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const { matches } = await findSessions('plugin', agentDir)
|
|
153
|
+
expect(matches).toHaveLength(1)
|
|
154
|
+
expect(matches[0].sessionId).toBe('kw-aaaa')
|
|
155
|
+
expect(matches[0].firstMessagePreview).toContain('plugin')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('无匹配返回 { matches: [], truncated: false }', async () => {
|
|
159
|
+
await makeSession(slugDir, { name: 'a.jsonl', id: '019abcd', cwd: '/demo' })
|
|
160
|
+
// query 非 uuid 特征(z 非十六进制),走首消息 fallback 仍无匹配
|
|
161
|
+
const result = await findSessions('zzznotexist', agentDir)
|
|
162
|
+
expect(result).toEqual({ matches: [], truncated: false })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('firstMessagePreview 截断到 80 字符', async () => {
|
|
166
|
+
const longText = 'X'.repeat(200)
|
|
167
|
+
await makeSession(slugDir, {
|
|
168
|
+
name: 'a.jsonl',
|
|
169
|
+
id: 'prev-len',
|
|
170
|
+
cwd: '/demo',
|
|
171
|
+
firstUserText: longText,
|
|
172
|
+
})
|
|
173
|
+
const { matches } = await findSessions('prev-len', agentDir)
|
|
174
|
+
expect(matches).toHaveLength(1)
|
|
175
|
+
expect(matches[0].firstMessagePreview).toBeDefined()
|
|
176
|
+
expect(matches[0].firstMessagePreview!.length).toBeLessThanOrEqual(80)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('纯十六进制 query 无 uuid 匹配时不走首消息 fallback(uuid 特征短路)', async () => {
|
|
180
|
+
// 'deadbeef' 全十六进制 → uuid 特征;sessionId 不含、首消息也不会含 → matched=[]
|
|
181
|
+
await makeSession(slugDir, {
|
|
182
|
+
name: 'a.jsonl',
|
|
183
|
+
id: '019aaaa',
|
|
184
|
+
cwd: '/demo',
|
|
185
|
+
firstUserText: 'deadbeef 出现在首消息里也不该匹配',
|
|
186
|
+
})
|
|
187
|
+
const result = await findSessions('deadbeef', agentDir)
|
|
188
|
+
expect(result.matches).toEqual([])
|
|
189
|
+
expect(result.truncated).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it.skipIf(!HAS_E6)('真实数据:e6c96 匹配 019e6c96 开头的 session', async () => {
|
|
193
|
+
const { matches } = await findSessions('e6c96', REAL_AGENT_DIR)
|
|
194
|
+
expect(matches.length).toBeGreaterThan(0)
|
|
195
|
+
expect(matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
|
|
196
|
+
// 真实值校验
|
|
197
|
+
const hit = matches.find((m) => m.sessionId.startsWith('019e6c96'))!
|
|
198
|
+
expect(hit.fileName).toContain('019e6c96')
|
|
199
|
+
expect(hit.mtime).toBeGreaterThan(0)
|
|
200
|
+
expect(hit.sizeBytes).toBeGreaterThan(0)
|
|
201
|
+
}, 30000)
|
|
202
|
+
|
|
203
|
+
it.skipIf(!HAS_E6)("真实数据:recent 返回最近 N 个,mtime 倒序,truncated=true", async () => {
|
|
204
|
+
const { matches, truncated } = await findSessions('recent', REAL_AGENT_DIR, { limit: 5 })
|
|
205
|
+
expect(matches.length).toBeGreaterThan(0)
|
|
206
|
+
expect(matches.length).toBeLessThanOrEqual(5)
|
|
207
|
+
// mtime 倒序
|
|
208
|
+
for (let i = 1; i < matches.length; i++) {
|
|
209
|
+
expect(matches[i - 1].mtime).toBeGreaterThanOrEqual(matches[i].mtime)
|
|
210
|
+
}
|
|
211
|
+
// 真实 session 文件远多于 5 → 截断
|
|
212
|
+
expect(truncated).toBe(true)
|
|
213
|
+
}, 30000)
|
|
214
|
+
})
|