@zhushanwen/pi-session-reader 0.10.4 → 0.12.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/package.json +7 -6
- package/src/__tests__/cross-package-subagent-core.test.ts +34 -25
- package/src/__tests__/entry-anchor.test.ts +3 -3
- package/src/__tests__/execution-tree.test.ts +85 -86
- package/src/__tests__/family-zcode.test.ts +24 -14
- package/src/__tests__/family.test.ts +4 -4
- package/src/__tests__/find.test.ts +50 -5
- package/src/__tests__/index.test.ts +14 -3
- package/src/__tests__/rebuild-manifest-dual-write.test.ts +29 -22
- package/src/__tests__/render.test.ts +2 -2
- package/src/__tests__/result.test.ts +20 -11
- package/src/__tests__/roots.test.ts +64 -26
- package/src/__tests__/subagents.test.ts +108 -52
- package/src/__tests__/summary.test.ts +223 -0
- package/src/__tests__/tool-handler.test.ts +133 -83
- package/src/__tests__/workflow.test.ts +5 -5
- package/src/__tests__/zcode-manifest.test.ts +18 -10
- package/src/__tests__/zcode-routing.test.ts +21 -19
- package/src/__tests__/zcode-whitelist.test.ts +1 -1
- package/src/core/family.ts +6 -6
- package/src/core/render.ts +2 -2
- package/src/core/toolcall.ts +1 -1
- package/src/core/workflow.ts +9 -9
- package/src/discovery/entry-anchor.ts +5 -5
- package/src/discovery/find.ts +9 -1
- package/src/discovery/roots.ts +95 -7
- package/src/discovery/subagents.ts +24 -17
- package/src/discovery/whitelist.ts +4 -4
- package/src/discovery/workflows.ts +8 -8
- package/src/discovery/zcode-manifest.ts +22 -27
- package/src/extract.ts +1 -1
- package/src/index.ts +12 -4
- package/src/no-match.ts +1 -1
- package/src/result-action.ts +1 -1
- package/src/search-across.ts +3 -3
- package/src/summary.ts +178 -0
- package/src/tool-format.ts +12 -12
- package/src/tool-handler.ts +27 -19
- package/src/tui/session-command.ts +1 -1
- package/src/tui/tool-render.ts +48 -0
- package/src/zcode-anchor-classify.ts +2 -2
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
extractSessionIdFromFilename,
|
|
9
9
|
type RecordManifest,
|
|
10
10
|
} from '../discovery/subagents.js'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
resolveSessionRoots,
|
|
13
|
+
resolveStoreSubagentSessionsDir,
|
|
14
|
+
resolveStoreSubagentRecordsDir,
|
|
15
|
+
} from '../discovery/roots.js'
|
|
12
16
|
import { parseSessionHeader, readSessionHeaderFirstLine } from '../discovery/session-header.js'
|
|
13
17
|
|
|
14
18
|
// ---- fixture 常量(uuid 特征,满足 extractSessionIdFromFilename + 互不为子串)----
|
|
@@ -18,10 +22,20 @@ const SUB_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000003'
|
|
|
18
22
|
|
|
19
23
|
// ---- fixture helpers ----
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 建 mkdtemp dataDir 根(生产拓扑复刻,[session-store-layout-inversion] 布局 B):
|
|
27
|
+
* agentDir = `<dataDir>/agent`(调用方经 agentDirOf 推导并传入被测函数),存储树 =
|
|
28
|
+
* `<dataDir>/ext-sessions`(fixture 经 roots.ts 生产同源推导函数落盘,禁手拼树根段字面量)。
|
|
29
|
+
*/
|
|
21
30
|
async function makeAgentDir(): Promise<string> {
|
|
22
31
|
return mkdtemp(join(tmpdir(), 'subagents-test-'))
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/** agentDir 推导(生产拓扑:agentDir = <dataDir>/agent,session-reader 的 agentDir 语义)。 */
|
|
35
|
+
function agentDirOf(dataDir: string): string {
|
|
36
|
+
return join(dataDir, 'agent')
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/** 写主 session 文件(首行 header)。返回绝对路径(供 fork 的 parentSession 指向)。 */
|
|
26
40
|
async function writeMainSession(
|
|
27
41
|
dir: string,
|
|
@@ -39,21 +53,22 @@ async function writeMainSession(
|
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/**
|
|
42
|
-
* 写 subagent session
|
|
56
|
+
* 写 subagent session 文件(新布局存储树:`<root>/<rootSessionId>/subagents/sessions/`,
|
|
57
|
+
* 分区键 = identity.rootSessionId,生产同源 resolveStoreSubagentSessionsDir 推导):
|
|
58
|
+
* 首行 header(真实 id)+ 占位 message + 尾行 identity。
|
|
43
59
|
* identity 在尾行(实测 pi 行为;subagent-identity 由 session-runner 在 session 创建后写)。
|
|
44
60
|
* U4 扩展:identity.task/agent 可定制(P-fallback 测试用,默认 't'/'explorer')。
|
|
45
61
|
*/
|
|
46
62
|
async function writeSubagentSession(
|
|
47
63
|
dir: string,
|
|
48
|
-
|
|
49
|
-
realId: string,
|
|
64
|
+
realId: string,
|
|
50
65
|
identity: { rootSessionId: string; slug: string; dataId?: string; task?: string; agent?: string },
|
|
51
66
|
): Promise<string> {
|
|
52
|
-
const sessionDir =
|
|
67
|
+
const sessionDir = resolveStoreSubagentSessionsDir(dir, identity.rootSessionId)
|
|
53
68
|
await mkdir(sessionDir, { recursive: true })
|
|
54
69
|
const path = join(sessionDir, `${realId}.jsonl`)
|
|
55
70
|
const lines = [
|
|
56
|
-
JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${slug}` }),
|
|
71
|
+
JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${identity.slug}` }),
|
|
57
72
|
JSON.stringify({
|
|
58
73
|
type: 'message',
|
|
59
74
|
id: 'm1',
|
|
@@ -84,14 +99,14 @@ async function writeSubagentSession(
|
|
|
84
99
|
*/
|
|
85
100
|
async function writeAliveSubagentNoIdentity(
|
|
86
101
|
dir: string,
|
|
87
|
-
|
|
102
|
+
rootSessionId: string,
|
|
88
103
|
realId: string,
|
|
89
104
|
): Promise<string> {
|
|
90
|
-
const sessionDir =
|
|
105
|
+
const sessionDir = resolveStoreSubagentSessionsDir(dir, rootSessionId)
|
|
91
106
|
await mkdir(sessionDir, { recursive: true })
|
|
92
107
|
const path = join(sessionDir, `${realId}.jsonl`)
|
|
93
108
|
const lines = [
|
|
94
|
-
JSON.stringify({ type: 'session', id: realId, cwd:
|
|
109
|
+
JSON.stringify({ type: 'session', id: realId, cwd: '/proj/root' }),
|
|
95
110
|
JSON.stringify({
|
|
96
111
|
type: 'message',
|
|
97
112
|
id: 'm1',
|
|
@@ -103,10 +118,13 @@ async function writeAliveSubagentNoIdentity(
|
|
|
103
118
|
return path
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* 写 records manifest(孤儿源;新布局 `<root>/<rootSessionId>/subagents/records/`,
|
|
123
|
+
* 分区键 = fields.rootSessionId,生产同源 resolveStoreSubagentRecordsDir 推导)。
|
|
124
|
+
* U4 扩展:fields 支持富字段 task/slug/model/status。
|
|
125
|
+
*/
|
|
107
126
|
async function writeRecordManifest(
|
|
108
127
|
dir: string,
|
|
109
|
-
slug: string,
|
|
110
128
|
id: string,
|
|
111
129
|
fields: {
|
|
112
130
|
rootSessionId: string
|
|
@@ -118,7 +136,7 @@ async function writeRecordManifest(
|
|
|
118
136
|
status?: string
|
|
119
137
|
},
|
|
120
138
|
): Promise<void> {
|
|
121
|
-
const recordsDir =
|
|
139
|
+
const recordsDir = resolveStoreSubagentRecordsDir(dir, fields.rootSessionId)
|
|
122
140
|
await mkdir(recordsDir, { recursive: true })
|
|
123
141
|
await writeFile(join(recordsDir, `${id}.json`), JSON.stringify({ id, ...fields }))
|
|
124
142
|
}
|
|
@@ -128,13 +146,15 @@ async function writeRecordManifest(
|
|
|
128
146
|
// ============================================================
|
|
129
147
|
|
|
130
148
|
describe('buildFamilyFromFs - fixture', () => {
|
|
131
|
-
let
|
|
149
|
+
let dataDir: string
|
|
150
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
132
151
|
|
|
133
152
|
beforeEach(async () => {
|
|
134
|
-
|
|
153
|
+
dataDir = await makeAgentDir()
|
|
154
|
+
dir = agentDirOf(dataDir)
|
|
135
155
|
})
|
|
136
156
|
afterEach(async () => {
|
|
137
|
-
await rm(
|
|
157
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
138
158
|
})
|
|
139
159
|
|
|
140
160
|
it('基础 family:root + fork + 隔代 subagent,SubagentRef.sessionId 是真实 id(非 sa-xxx)', async () => {
|
|
@@ -145,7 +165,7 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
145
165
|
parentSession: rootPath,
|
|
146
166
|
})
|
|
147
167
|
// subagent 挂在 fork 子代下(rootSessionId=FORK,非 ROOT)→ 隔代
|
|
148
|
-
await writeSubagentSession(dir,
|
|
168
|
+
await writeSubagentSession(dir, SUB_REAL, {
|
|
149
169
|
rootSessionId: FORK,
|
|
150
170
|
slug: 'test-sub',
|
|
151
171
|
dataId: 'sa-placeholder-1',
|
|
@@ -174,7 +194,7 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
174
194
|
it('从 fork 子代 resolve 也能关联到挂在其下的 subagent', async () => {
|
|
175
195
|
const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT)
|
|
176
196
|
await writeMainSession(dir, '--fork-cwd--', FORK, { parentSession: rootPath })
|
|
177
|
-
await writeSubagentSession(dir,
|
|
197
|
+
await writeSubagentSession(dir, SUB_REAL, { rootSessionId: FORK, slug: 's' })
|
|
178
198
|
|
|
179
199
|
const family = await buildFamilyFromFs(FORK, dir)
|
|
180
200
|
expect(family.subagents.some((s) => s.sessionId === SUB_REAL)).toBe(true)
|
|
@@ -183,7 +203,7 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
183
203
|
it('cleanedUp:manifest 孤儿(.jsonl 不存在)→ cleanedUp=true', async () => {
|
|
184
204
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
185
205
|
// 孤儿 manifest:rootSessionId=ROOT,sessionFile 指向不存在路径(模拟 .jsonl 被 GC)
|
|
186
|
-
await writeRecordManifest(dir, '
|
|
206
|
+
await writeRecordManifest(dir, 'sa-ghost-id', {
|
|
187
207
|
rootSessionId: ROOT,
|
|
188
208
|
agentName: 'explorer',
|
|
189
209
|
sessionFile: '/nonexistent/ghost.jsonl',
|
|
@@ -203,12 +223,12 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
203
223
|
it('alive subagent 同时有 manifest → 不重复计数(manifest 跳过 alive)', async () => {
|
|
204
224
|
const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT)
|
|
205
225
|
await writeMainSession(dir, '--fork-cwd--', FORK, { parentSession: rootPath })
|
|
206
|
-
const subPath = await writeSubagentSession(dir,
|
|
226
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
207
227
|
rootSessionId: FORK,
|
|
208
228
|
slug: 'dup-test',
|
|
209
229
|
})
|
|
210
230
|
// manifest 指向真实文件路径(alive)→ 应被跳过,不产生孤儿副本
|
|
211
|
-
await writeRecordManifest(dir,
|
|
231
|
+
await writeRecordManifest(dir, `sa-${SUB_REAL}`, {
|
|
212
232
|
rootSessionId: FORK,
|
|
213
233
|
agentName: 'explorer',
|
|
214
234
|
sessionFile: subPath,
|
|
@@ -231,16 +251,14 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
231
251
|
it('workflows:v2 注册条目 record 流直读提 calls;命中 pathToRef 取完整 ref,GC\'d 路径回退最小 ref', async () => {
|
|
232
252
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
233
253
|
// 真实存在的 subagent(步骤 2 扫到 → pathToRef 命中 → 完整 SessionRef)
|
|
234
|
-
const subPath = await writeSubagentSession(dir,
|
|
254
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
235
255
|
rootSessionId: ROOT,
|
|
236
256
|
slug: 'wf-sub',
|
|
237
257
|
})
|
|
238
|
-
// GC'd 路径(文件不存在 → pathToRef 未命中 → sessionRefFromPath 最小 ref
|
|
258
|
+
// GC'd 路径(文件不存在 → pathToRef 未命中 → sessionRefFromPath 最小 ref);
|
|
259
|
+
// 新布局 subagent 会话路径形态(存储树分区,生产同源推导)
|
|
239
260
|
const gced = join(
|
|
240
|
-
dir,
|
|
241
|
-
'subagents',
|
|
242
|
-
'--root-cwd--',
|
|
243
|
-
'sessions',
|
|
261
|
+
resolveStoreSubagentSessionsDir(dir, ROOT),
|
|
244
262
|
'2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl',
|
|
245
263
|
)
|
|
246
264
|
// record 流:agent-settled 帧携带 result.sessionFile(两 call:命中 + GC 各一)
|
|
@@ -286,7 +304,7 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
286
304
|
expect(wf.calls[0].sessionId).toBe(SUB_REAL)
|
|
287
305
|
expect(wf.calls[0].mtime).toBeGreaterThan(0)
|
|
288
306
|
expect(wf.calls[0].sizeBytes).toBeGreaterThan(0)
|
|
289
|
-
expect(wf.calls[0].cwd).toBe('/proj
|
|
307
|
+
expect(wf.calls[0].cwd).toBe('/proj/wf-sub') // cwd 从 subagent 文件首行 header 透传(fixture header.cwd = /proj/<identity.slug>)
|
|
290
308
|
// GC'd 未命中:fileName-only 最小 ref(sessionId 从文件名提取,mtime/size/cwd 占位)
|
|
291
309
|
expect(wf.calls[1].fileName).toBe(gced)
|
|
292
310
|
expect(wf.calls[1].sessionId).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
|
|
@@ -298,14 +316,14 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
298
316
|
it('MF-3 回归:alive 但无 identity 的 subagent(运行中)不被收编为 cleanedUp——U4 后 manifest 主路径建族', async () => {
|
|
299
317
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
300
318
|
// 运行中 subagent:有效 header、无 identity 尾行(identity 完成时才写入),manifest 已存在
|
|
301
|
-
const subDir =
|
|
319
|
+
const subDir = resolveStoreSubagentSessionsDir(dir, ROOT)
|
|
302
320
|
await mkdir(subDir, { recursive: true })
|
|
303
321
|
const subPath = join(subDir, SUB_REAL + '.jsonl')
|
|
304
322
|
await writeFile(
|
|
305
323
|
subPath,
|
|
306
324
|
JSON.stringify({ type: 'session', id: SUB_REAL, cwd: '/proj/root' }) + '\n',
|
|
307
325
|
)
|
|
308
|
-
await writeRecordManifest(dir,
|
|
326
|
+
await writeRecordManifest(dir, `sa-${SUB_REAL}`, {
|
|
309
327
|
rootSessionId: ROOT,
|
|
310
328
|
agentName: 'explorer',
|
|
311
329
|
sessionFile: subPath,
|
|
@@ -332,17 +350,19 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
332
350
|
// ============================================================
|
|
333
351
|
|
|
334
352
|
describe('listRecordManifests 导出(w2 TC1)', () => {
|
|
335
|
-
let
|
|
353
|
+
let dataDir: string
|
|
354
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
336
355
|
|
|
337
356
|
beforeEach(async () => {
|
|
338
|
-
|
|
357
|
+
dataDir = await makeAgentDir()
|
|
358
|
+
dir = agentDirOf(dataDir)
|
|
339
359
|
})
|
|
340
360
|
afterEach(async () => {
|
|
341
|
-
await rm(
|
|
361
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
342
362
|
})
|
|
343
363
|
|
|
344
364
|
it('导出生效:import + 调用返回 RecordManifest[],字段完整', async () => {
|
|
345
|
-
await writeRecordManifest(dir, '
|
|
365
|
+
await writeRecordManifest(dir, 'sa-tc1', {
|
|
346
366
|
rootSessionId: 'root-1',
|
|
347
367
|
agentName: 'explorer',
|
|
348
368
|
sessionFile: '/tmp/sa-tc1.jsonl',
|
|
@@ -355,6 +375,38 @@ describe('listRecordManifests 导出(w2 TC1)', () => {
|
|
|
355
375
|
expect(m.agentName).toBe('explorer')
|
|
356
376
|
expect(m.sessionFile).toBe('/tmp/sa-tc1.jsonl')
|
|
357
377
|
})
|
|
378
|
+
|
|
379
|
+
it('D6 单新根锚定:旧布局 <agentDir>/subagents 造 record + 新根不造 → 不返回(存量不可见,防回归双根扫描)', async () => {
|
|
380
|
+
// 旧布局存量原位保留(D6 裁决:不迁移、不再读写)——旧根造一份合法 manifest
|
|
381
|
+
const legacyRecordsDir = join(dir, 'subagents', '--legacy-cwd--', 'records')
|
|
382
|
+
await mkdir(legacyRecordsDir, { recursive: true })
|
|
383
|
+
await writeFile(
|
|
384
|
+
join(legacyRecordsDir, 'sa-legacy.json'),
|
|
385
|
+
JSON.stringify({ id: 'sa-legacy', rootSessionId: 'root-legacy', sessionFile: '/nonexistent/legacy.jsonl' }),
|
|
386
|
+
)
|
|
387
|
+
// 单新根语义:旧根 manifest 不进结果集(result/find 面旧记录不可见是 D6 取舍清单明示项)
|
|
388
|
+
expect(await listRecordManifests(dir)).toHaveLength(0)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('D6 单新根锚定:非会话分区目录(isPiSessionId 不过)剪枝跳过,合法分区正常读', async () => {
|
|
392
|
+
const { resolveSessionStoreRoot } = await import('../discovery/roots.js')
|
|
393
|
+
const storeRoot = resolveSessionStoreRoot(dir)
|
|
394
|
+
// 非法分区名(前导 - 违反 isPiSessionId 首字符规则)下的 records 不扫
|
|
395
|
+
const bogus = join(storeRoot, '-bogus-partition', 'subagents', 'records')
|
|
396
|
+
await mkdir(bogus, { recursive: true })
|
|
397
|
+
await writeFile(
|
|
398
|
+
join(bogus, 'sa-bogus.json'),
|
|
399
|
+
JSON.stringify({ id: 'sa-bogus', rootSessionId: '-bogus-partition', sessionFile: '/nonexistent/b.jsonl' }),
|
|
400
|
+
)
|
|
401
|
+
// 合法分区在场
|
|
402
|
+
await writeRecordManifest(dir, 'sa-ok', {
|
|
403
|
+
rootSessionId: 'root-ok',
|
|
404
|
+
agentName: 'explorer',
|
|
405
|
+
sessionFile: '/tmp/sa-ok.jsonl',
|
|
406
|
+
})
|
|
407
|
+
const manifests = await listRecordManifests(dir)
|
|
408
|
+
expect(manifests.map((m) => m.id)).toEqual(['sa-ok'])
|
|
409
|
+
})
|
|
358
410
|
})
|
|
359
411
|
|
|
360
412
|
// ============================================================
|
|
@@ -363,25 +415,27 @@ describe('listRecordManifests 导出(w2 TC1)', () => {
|
|
|
363
415
|
// ============================================================
|
|
364
416
|
|
|
365
417
|
describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
366
|
-
let
|
|
418
|
+
let dataDir: string
|
|
419
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
367
420
|
|
|
368
421
|
beforeEach(async () => {
|
|
369
|
-
|
|
422
|
+
dataDir = await makeAgentDir()
|
|
423
|
+
dir = agentDirOf(dataDir)
|
|
370
424
|
})
|
|
371
425
|
afterEach(async () => {
|
|
372
|
-
await rm(
|
|
426
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
373
427
|
})
|
|
374
428
|
|
|
375
429
|
it('TC-u4-manifest-enrich: alive + manifest 全字段 → SubagentRef 富字段全透传', async () => {
|
|
376
430
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
377
|
-
const subPath = await writeSubagentSession(dir,
|
|
431
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
378
432
|
rootSessionId: ROOT,
|
|
379
433
|
slug: 'identity-slug', // identity.slug,manifest 主时被 manifest.slug 覆盖
|
|
380
434
|
task: 'identity-task', // identity.task,manifest 主时被 manifest.task 覆盖
|
|
381
435
|
agent: 'worker', // identity.agent,manifest 主时被 manifest.agentName 覆盖
|
|
382
436
|
})
|
|
383
437
|
// manifest 全字段(命中 meta.path 索引 → 走 manifest 主路径,覆盖 identity 值)
|
|
384
|
-
await writeRecordManifest(dir,
|
|
438
|
+
await writeRecordManifest(dir, `sa-${SUB_REAL}`, {
|
|
385
439
|
rootSessionId: ROOT,
|
|
386
440
|
agentName: 'explorer',
|
|
387
441
|
sessionFile: subPath,
|
|
@@ -409,7 +463,7 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
|
409
463
|
it('TC-u4-pfallback-identity: alive 无 manifest + identity 含 task/agent → 回退 identity,model/status undefined', async () => {
|
|
410
464
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
411
465
|
// 只写 alive session(含 identity 尾行),不写 manifest → P-fallback 路径
|
|
412
|
-
const subPath = await writeSubagentSession(dir,
|
|
466
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
413
467
|
rootSessionId: ROOT,
|
|
414
468
|
slug: 'fix',
|
|
415
469
|
task: 'fix bug',
|
|
@@ -434,7 +488,7 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
|
434
488
|
it('TC-u4-pfallback-no-identity: alive 无 manifest 无 identity(运行中)→ 不入 family.subagents,不抛错', async () => {
|
|
435
489
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
436
490
|
// alive 文件无 identity 尾行(运行中/异常),无 manifest
|
|
437
|
-
await writeAliveSubagentNoIdentity(dir,
|
|
491
|
+
await writeAliveSubagentNoIdentity(dir, ROOT, SUB_REAL)
|
|
438
492
|
|
|
439
493
|
// 核心断言:buildFamilyFromFs 不抛错、不崩溃
|
|
440
494
|
const family = await buildFamilyFromFs(ROOT, dir)
|
|
@@ -447,7 +501,7 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
|
447
501
|
it('TC-u4-orphan-manifest: manifest 全字段 + sessionFile 指向不存在路径 → 孤儿 cleanedUp=true,富字段透传', async () => {
|
|
448
502
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
449
503
|
const ghostPath = '/nonexistent/gc-ghost.jsonl' // .jsonl 已被 GC(不写该文件)
|
|
450
|
-
await writeRecordManifest(dir, '
|
|
504
|
+
await writeRecordManifest(dir, 'sa-ghost-id', {
|
|
451
505
|
rootSessionId: ROOT,
|
|
452
506
|
agentName: 'worker',
|
|
453
507
|
sessionFile: ghostPath,
|
|
@@ -475,7 +529,7 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
|
475
529
|
it('TC-u4-recordmanifest-compat: 旧 manifest(仅 id/rootSessionId/sessionFile)→ 富字段 undefined,不抛错', async () => {
|
|
476
530
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
477
531
|
// 旧格式 manifest:无 task/slug/model/status/agentName
|
|
478
|
-
await writeRecordManifest(dir, '
|
|
532
|
+
await writeRecordManifest(dir, 'sa-old', {
|
|
479
533
|
rootSessionId: ROOT,
|
|
480
534
|
sessionFile: '/nonexistent/old.jsonl',
|
|
481
535
|
})
|
|
@@ -518,18 +572,20 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
|
518
572
|
// 扫描器即可与该文件族共存)。
|
|
519
573
|
|
|
520
574
|
describe('.events 文件族结构性忽略(W1 D3 / 检查点④)', () => {
|
|
521
|
-
let
|
|
575
|
+
let dataDir: string
|
|
576
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
522
577
|
|
|
523
578
|
beforeEach(async () => {
|
|
524
|
-
|
|
579
|
+
dataDir = await makeAgentDir()
|
|
580
|
+
dir = agentDirOf(dataDir)
|
|
525
581
|
})
|
|
526
582
|
afterEach(async () => {
|
|
527
|
-
await rm(
|
|
583
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
528
584
|
})
|
|
529
585
|
|
|
530
586
|
/** 写 record 事件文件(首行头行 + 事件行集,形态对齐写侧 record-events.ts)。 */
|
|
531
|
-
async function writeEventsFile(
|
|
532
|
-
const recordsDir =
|
|
587
|
+
async function writeEventsFile(saId: string): Promise<string> {
|
|
588
|
+
const recordsDir = resolveStoreSubagentRecordsDir(dir, ROOT)
|
|
533
589
|
await mkdir(recordsDir, { recursive: true })
|
|
534
590
|
const path = join(recordsDir, `${saId}.events`)
|
|
535
591
|
const lines = [
|
|
@@ -543,11 +599,11 @@ describe('.events 文件族结构性忽略(W1 D3 / 检查点④)', () => {
|
|
|
543
599
|
|
|
544
600
|
it('subagent 树扫描不收 .events:resolveSessionRoots 的 subagent 根 files 含 .jsonl 不含 .events', async () => {
|
|
545
601
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
546
|
-
const subPath = await writeSubagentSession(dir,
|
|
602
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
547
603
|
rootSessionId: ROOT,
|
|
548
604
|
slug: 'ev-sibling',
|
|
549
605
|
})
|
|
550
|
-
await writeEventsFile('
|
|
606
|
+
await writeEventsFile('sa-ev-1')
|
|
551
607
|
|
|
552
608
|
const roots = await resolveSessionRoots({ agentDir: dir })
|
|
553
609
|
const subRoot = roots.find((r) => r.kind === 'subagent')
|
|
@@ -558,7 +614,7 @@ describe('.events 文件族结构性忽略(W1 D3 / 检查点④)', () => {
|
|
|
558
614
|
})
|
|
559
615
|
|
|
560
616
|
it('首行头行命中非 session header 即忽略:record-journal 头行 → parseSessionHeader 返回 null', async () => {
|
|
561
|
-
const eventsPath = await writeEventsFile('
|
|
617
|
+
const eventsPath = await writeEventsFile('sa-ev-1')
|
|
562
618
|
// 检查点④形态:session-reader 对未知文件读首行判 header,非 session 行 → null
|
|
563
619
|
//(即使未来通配规则把 .events 收进候选集,该文件也不会被误认成 session)
|
|
564
620
|
const firstLine = await readSessionHeaderFirstLine(eventsPath)
|
|
@@ -568,11 +624,11 @@ describe('.events 文件族结构性忽略(W1 D3 / 检查点④)', () => {
|
|
|
568
624
|
|
|
569
625
|
it('家族扫描:.events 与正常 subagent 同目录共存 → 家族正常、不产幻影 subagent 节点', async () => {
|
|
570
626
|
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
571
|
-
await writeSubagentSession(dir,
|
|
627
|
+
await writeSubagentSession(dir, SUB_REAL, {
|
|
572
628
|
rootSessionId: ROOT,
|
|
573
629
|
slug: 'ev-coexist',
|
|
574
630
|
})
|
|
575
|
-
await writeEventsFile('
|
|
631
|
+
await writeEventsFile('sa-ev-1')
|
|
576
632
|
|
|
577
633
|
const family = await buildFamilyFromFs(ROOT, dir)
|
|
578
634
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type { SessionReadParams, ToolResult } from '../tool-handler.js'
|
|
5
|
+
import { buildSessionReadSummary, withResultSummary } from '../summary.js'
|
|
6
|
+
import { renderSessionReadResult } from '../tui/tool-render.js'
|
|
7
|
+
|
|
8
|
+
// ---- fixtures ----
|
|
9
|
+
|
|
10
|
+
function params(overrides: Partial<SessionReadParams> & { action: SessionReadParams['action'] }): SessionReadParams {
|
|
11
|
+
return overrides as SessionReadParams
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toolResult(details: unknown, text = 'body text'): ToolResult {
|
|
15
|
+
return { content: [{ type: 'text', text }], details }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---- buildSessionReadSummary ----
|
|
19
|
+
|
|
20
|
+
describe('buildSessionReadSummary', () => {
|
|
21
|
+
it('find → Found N sessions matching "<query>"(截断带后缀)', () => {
|
|
22
|
+
const r = toolResult({ matches: [{}, {}, {}], truncated: false })
|
|
23
|
+
expect(buildSessionReadSummary(params({ action: 'find', query: 'login' }), r)).toBe(
|
|
24
|
+
'Found 3 sessions matching "login"',
|
|
25
|
+
)
|
|
26
|
+
const truncated = toolResult({ matches: [{}], truncated: true })
|
|
27
|
+
expect(buildSessionReadSummary(params({ action: 'find', query: 'login' }), truncated)).toBe(
|
|
28
|
+
'Found 1 sessions matching "login" (truncated)',
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('find 零匹配 → No sessions matching', () => {
|
|
33
|
+
const r = toolResult({ matches: [], truncated: false })
|
|
34
|
+
expect(buildSessionReadSummary(params({ action: 'find', query: 'gone' }), r)).toBe(
|
|
35
|
+
'No sessions matching "gone"',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('ambiguous(任意 action 的多匹配消歧)→ N candidates', () => {
|
|
40
|
+
const r = toolResult({ ambiguous: true, candidates: [{}, {}] })
|
|
41
|
+
expect(buildSessionReadSummary(params({ action: 'outline', session: 'e6c9' }), r)).toBe(
|
|
42
|
+
'Ambiguous session "e6c9": 2 candidates',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('family flat → N relations(parents/forks/subagents/workflows 合计)', () => {
|
|
47
|
+
const family = {
|
|
48
|
+
root: { sessionId: 's1' },
|
|
49
|
+
parents: [{}],
|
|
50
|
+
forks: [{}, {}],
|
|
51
|
+
subagents: [{}],
|
|
52
|
+
workflows: [],
|
|
53
|
+
}
|
|
54
|
+
const r = toolResult(family)
|
|
55
|
+
expect(buildSessionReadSummary(params({ action: 'family', session: 's1' }), r)).toBe(
|
|
56
|
+
'Family of session s1: 4 relations',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('family recursive(details.tree)→ Execution tree', () => {
|
|
61
|
+
const r = toolResult({ tree: { root: {} } })
|
|
62
|
+
expect(buildSessionReadSummary(params({ action: 'family', session: 's1', recursive: true }), r)).toBe(
|
|
63
|
+
'Execution tree of session s1',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('outline → N turns(stats.totalTurns)', () => {
|
|
68
|
+
const r = toolResult({ turns: [], lines: [], stats: { totalTurns: 32, totalEntries: 90, totalBytes: 1, parsedBytes: 1, skippedLines: 0 }, tokenEstimate: 1 })
|
|
69
|
+
expect(buildSessionReadSummary(params({ action: 'outline', session: 's1' }), r)).toBe(
|
|
70
|
+
'Outline of session s1 · 32 turns',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('expand → T 序号补零 + N entries', () => {
|
|
75
|
+
const r = toolResult({ turn: { index: 13 }, entries: [{}, {}, {}, {}] })
|
|
76
|
+
expect(buildSessionReadSummary(params({ action: 'expand', session: 's1', turn: 'T013' }), r)).toBe(
|
|
77
|
+
'Expanded turn T013 · 4 entries',
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('detail → turns 范围标签 + N entries', () => {
|
|
82
|
+
const r = toolResult({ turns: 'T013-T015', entries: [{}, {}, {}] })
|
|
83
|
+
expect(buildSessionReadSummary(params({ action: 'detail', session: 's1', turns: 'T013-T015' }), r)).toBe(
|
|
84
|
+
'Read turns T013-T015 · 3 entries',
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('search → Found N hits / No hits', () => {
|
|
89
|
+
const r = toolResult({ hits: [{}, {}], truncated: false })
|
|
90
|
+
expect(buildSessionReadSummary(params({ action: 'search', session: 's1', pattern: 'fix' }), r)).toBe(
|
|
91
|
+
'Found 2 hits for "fix"',
|
|
92
|
+
)
|
|
93
|
+
const empty = toolResult({ hits: [], truncated: false })
|
|
94
|
+
expect(buildSessionReadSummary(params({ action: 'search', session: 's1', pattern: 'fix' }), empty)).toBe(
|
|
95
|
+
'No hits for "fix"',
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('export → format + sizeBytes', () => {
|
|
100
|
+
const r = toolResult({ path: '/tmp/x.md', sizeBytes: 2048 })
|
|
101
|
+
expect(buildSessionReadSummary(params({ action: 'export', session: 's1' }), r)).toBe(
|
|
102
|
+
'Exported outline view · 2048 bytes',
|
|
103
|
+
)
|
|
104
|
+
expect(buildSessionReadSummary(params({ action: 'export', session: 's1', format: 'full' }), r)).toBe(
|
|
105
|
+
'Exported full view · 2048 bytes',
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('extract → count / 零匹配 / 截断 showing', () => {
|
|
110
|
+
const r = toolResult({ what: 'user-messages', count: 26, shown: 26, truncated: false, items: [] })
|
|
111
|
+
expect(buildSessionReadSummary(params({ action: 'extract', session: 's1', what: 'user-messages' }), r)).toBe(
|
|
112
|
+
'Extracted 26 user-messages',
|
|
113
|
+
)
|
|
114
|
+
const empty = toolResult({ what: 'commands', count: 0, shown: 0, truncated: false, items: [] })
|
|
115
|
+
expect(buildSessionReadSummary(params({ action: 'extract', session: 's1', what: 'commands' }), empty)).toBe(
|
|
116
|
+
'No commands found',
|
|
117
|
+
)
|
|
118
|
+
const clipped = toolResult({ what: 'files', count: 500, shown: 120, truncated: true, items: [] })
|
|
119
|
+
expect(buildSessionReadSummary(params({ action: 'extract', session: 's1', what: 'files' }), clipped)).toBe(
|
|
120
|
+
'Extracted 500 files (showing 120)',
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('workflow → runs 计数 / 空 / skipped 后缀', () => {
|
|
125
|
+
expect(buildSessionReadSummary(params({ action: 'workflow', session: 's1' }), toolResult({ runs: [{}, {}], runIds: ['a', 'b'] }))).toBe(
|
|
126
|
+
'Found 2 workflow runs',
|
|
127
|
+
)
|
|
128
|
+
expect(buildSessionReadSummary(params({ action: 'workflow', session: 's1' }), toolResult({ runs: [], runIds: [] }))).toBe(
|
|
129
|
+
'No workflow runs',
|
|
130
|
+
)
|
|
131
|
+
expect(
|
|
132
|
+
buildSessionReadSummary(
|
|
133
|
+
params({ action: 'workflow', session: 's1' }),
|
|
134
|
+
toolResult({ runs: [{}], runIds: ['a'], skippedRuns: [{ runId: 'b', stateFile: 'x', reason: 'snapshot-unreadable' }] }),
|
|
135
|
+
),
|
|
136
|
+
).toBe('Found 1 workflow runs · 1 skipped')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('result 单条 → session + totalChars(截断后缀)', () => {
|
|
140
|
+
const r = toolResult({ session: 'sa-abc', sessionId: 'uuid', sessionFile: '/x', totalChars: 8000, truncated: true, text: '...' })
|
|
141
|
+
expect(buildSessionReadSummary(params({ action: 'result', session: 'sa-abc' }), r)).toBe(
|
|
142
|
+
'Read final result of sa-abc · 8000 chars (truncated)',
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('result 批量 → count + items 形态识别', () => {
|
|
147
|
+
const r = toolResult({ count: 3, items: [{}, {}, {}] })
|
|
148
|
+
expect(buildSessionReadSummary(params({ action: 'result', session: 'a,b,c' }), r)).toBe(
|
|
149
|
+
'Read 3 final results',
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('doctor → roots 计数', () => {
|
|
154
|
+
const r = toolResult({ environment: {}, roots: [{ kind: 'default' }, { kind: 'legacy' }] })
|
|
155
|
+
expect(buildSessionReadSummary(params({ action: 'doctor' }), r)).toBe(
|
|
156
|
+
'Doctor: 2 roots checked',
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('details 形态漂移(空对象)→ 各 action 兜底不抛错', () => {
|
|
161
|
+
const empty = toolResult({})
|
|
162
|
+
expect(buildSessionReadSummary(params({ action: 'outline', session: 's1' }), empty)).toBe(
|
|
163
|
+
'Outline of session s1',
|
|
164
|
+
)
|
|
165
|
+
expect(buildSessionReadSummary(params({ action: 'workflow', session: 's1' }), empty)).toBe(
|
|
166
|
+
'No workflow runs',
|
|
167
|
+
)
|
|
168
|
+
expect(buildSessionReadSummary(params({ action: 'doctor' }), empty)).toBe('Doctor check completed')
|
|
169
|
+
expect(buildSessionReadSummary(params({ action: 'extract', what: 'files' }), empty)).toBe('No files found')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ---- withResultSummary ----
|
|
174
|
+
|
|
175
|
+
describe('withResultSummary', () => {
|
|
176
|
+
it('details 追加 summary,现有字段保留', () => {
|
|
177
|
+
const r = toolResult({ hits: [{}], truncated: false })
|
|
178
|
+
const out = withResultSummary(params({ action: 'search', pattern: 'fix' }), r)
|
|
179
|
+
expect(out.details).toEqual({ hits: [{}], truncated: false, summary: 'Found 1 hits for "fix"' })
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ---- renderSessionReadResult(TUI renderResult)----
|
|
184
|
+
|
|
185
|
+
const mockTheme = {
|
|
186
|
+
fg: (_color: string, text: string) => text,
|
|
187
|
+
bg: (_color: string, text: string) => text,
|
|
188
|
+
bold: (text: string) => text,
|
|
189
|
+
italic: (text: string) => text,
|
|
190
|
+
underline: (text: string) => text,
|
|
191
|
+
inverse: (text: string) => text,
|
|
192
|
+
strikethrough: (text: string) => text,
|
|
193
|
+
getFgAnsi: (_color: string) => '',
|
|
194
|
+
getBgAnsi: (_color: string) => '',
|
|
195
|
+
getColorMode: () => 'truecolor' as const,
|
|
196
|
+
getThinkingBorderColor: () => (text: string) => text,
|
|
197
|
+
getBashModeBorderColor: () => (text: string) => text,
|
|
198
|
+
} as unknown as Theme
|
|
199
|
+
|
|
200
|
+
function renderedText(result: unknown, expanded: boolean): string {
|
|
201
|
+
return renderSessionReadResult(result, { expanded }, mockTheme)
|
|
202
|
+
.render(400)
|
|
203
|
+
.map((line) => line.trimEnd())
|
|
204
|
+
.join('\n')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
describe('renderSessionReadResult', () => {
|
|
208
|
+
it('折叠态 → details.summary 一行', () => {
|
|
209
|
+
const r = toolResult({ matches: [], truncated: false }, 'multi\nline\nbody')
|
|
210
|
+
const withSummary = withResultSummary(params({ action: 'find', query: 'login' }), r)
|
|
211
|
+
expect(renderedText(withSummary, false)).toBe('No sessions matching "login"')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('展开态 → content 纯文本(多行完整)', () => {
|
|
215
|
+
const r = toolResult({ matches: [] }, 'line1\nline2')
|
|
216
|
+
expect(renderedText(r, true)).toBe('line1\nline2')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('无 summary(旧 entry)→ 折叠态回退 content 首行', () => {
|
|
220
|
+
const r = toolResult({}, 'Header line\ndetail body')
|
|
221
|
+
expect(renderedText(r, false)).toBe('Header line')
|
|
222
|
+
})
|
|
223
|
+
})
|