@zhushanwen/pi-session-reader 0.5.1 → 0.6.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 +5 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +6 -5
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +5 -5
- package/src/__tests__/real-data.ts +1 -1
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +46 -80
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +4 -3
- package/src/__tests__/tool-handler.test.ts +419 -141
- package/src/__tests__/workflow.test.ts +1 -7
- package/src/core/execution-tree.ts +1 -0
- package/src/core/family.ts +12 -9
- package/src/core/render.ts +40 -12
- package/src/core/workflow.ts +7 -20
- package/src/discovery/find.ts +42 -134
- package/src/discovery/roots.ts +16 -87
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +11 -77
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +28 -33
- package/src/search-across.ts +5 -4
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"pi-package"
|
|
17
17
|
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@zhushanwen/pi-ext-guards": "0.4.0"
|
|
20
|
+
},
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@vitest/coverage-v8": "^4.1.9",
|
|
20
23
|
"vitest": "^4.1.8",
|
|
21
|
-
"@zhushanwen/subagent-core": "0.10.
|
|
24
|
+
"@zhushanwen/subagent-core": "0.10.1"
|
|
22
25
|
},
|
|
23
26
|
"files": [
|
|
24
27
|
"index.ts",
|
|
@@ -213,13 +213,14 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
213
213
|
return childFile
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
/** 种「崩溃前主文件末条序列」:register(running+sync)→ 轮终(
|
|
217
|
-
*
|
|
216
|
+
/** 种「崩溃前主文件末条序列」:register(running+sync)→ 轮终(idle + result
|
|
217
|
+
* 全文 + sessionFile)——[U4/D4] 翻边后成功成员崩溃时的真实末条形态(U5 前的
|
|
218
|
+
* running+resumable 桥接形态已随字段退役消亡)。 */
|
|
218
219
|
function seedRoundTerminalEntries(m: MemberSeed, childFile: string): void {
|
|
219
220
|
const store = makeSeedStore()
|
|
220
221
|
store.reportSubagentRecord(memberRecord({ id: m.id, sessionFile: childFile, model: m.model }))
|
|
221
222
|
store.reportSubagentRecord(
|
|
222
|
-
memberRecord({ id: m.id, sessionFile: childFile, model: m.model,
|
|
223
|
+
memberRecord({ id: m.id, sessionFile: childFile, model: m.model, status: 'idle', result: m.resultText }),
|
|
223
224
|
)
|
|
224
225
|
}
|
|
225
226
|
|
|
@@ -280,7 +281,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
280
281
|
})
|
|
281
282
|
|
|
282
283
|
// session-reader 侧:sa- id 反查(v1 此处报「无匹配 record」——manifest 惰性不落盘)
|
|
283
|
-
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, agentDir)
|
|
284
|
+
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, { agentDir })
|
|
284
285
|
// 同源语义:跨 assistant message "\n\n" join、thinking 块排除——与 record.result 逐字节一致
|
|
285
286
|
expect(r.content[0]?.text).toBe(resultText)
|
|
286
287
|
const d = r.details as {
|
|
@@ -304,7 +305,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
304
305
|
]
|
|
305
306
|
await driveSyncBatchRecovery(members)
|
|
306
307
|
|
|
307
|
-
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, agentDir)
|
|
308
|
+
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, { agentDir })
|
|
308
309
|
const text = r.content[0]?.text ?? ''
|
|
309
310
|
expect(text).toContain('[1/2] sa-cross-a')
|
|
310
311
|
expect(text).toContain('[2/2] sa-cross-b')
|
|
@@ -107,13 +107,6 @@ describe('buildFamilyIndex', () => {
|
|
|
107
107
|
expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
it('parentSession 直接是 sessionId 也兼容(简化 fixture)', () => {
|
|
111
|
-
const headers = [header('aaa'), header('bbb', { parentSession: 'aaa' })]
|
|
112
|
-
const index = buildFamilyIndex(headers, [], new Map())
|
|
113
|
-
|
|
114
|
-
expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
|
|
115
|
-
})
|
|
116
|
-
|
|
117
110
|
it('parentSession 反查不到父 → 该 entry 不进 childrenOf(不报错)', () => {
|
|
118
111
|
const headers = [header('bbb', { parentSession: '/missing.jsonl' })]
|
|
119
112
|
const index = buildFamilyIndex(headers, [], new Map())
|
|
@@ -133,7 +133,7 @@ async function makeSubagentSession(
|
|
|
133
133
|
describe('findSessions', () => {
|
|
134
134
|
let agentDir: string
|
|
135
135
|
let slugDir: string
|
|
136
|
-
/** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,
|
|
136
|
+
/** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,resolveSessionRoots [subagent] 根扫描路径) */
|
|
137
137
|
let saDir: string
|
|
138
138
|
|
|
139
139
|
let recordsDir: string
|
|
@@ -705,8 +705,8 @@ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
|
|
|
705
705
|
await mkdir(legacyDir, { recursive: true })
|
|
706
706
|
const provider = vi.fn(async (dir: string) =>
|
|
707
707
|
dir === flatMain
|
|
708
|
-
? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: 1000 }]
|
|
709
|
-
: [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: 2000 }],
|
|
708
|
+
? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: new Date(1000) }]
|
|
709
|
+
: [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: new Date(2000) }],
|
|
710
710
|
)
|
|
711
711
|
const { matches } = await findSessions('新标题', agentDir, { metadataProvider: provider })
|
|
712
712
|
expect(provider).toHaveBeenCalledTimes(2) // 两个平铺存在的根各一次
|
|
@@ -753,8 +753,8 @@ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
|
|
|
753
753
|
const provider = vi.fn(async (dir: string) =>
|
|
754
754
|
dir === flatMain
|
|
755
755
|
? [
|
|
756
|
-
{ path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: (base + 300) * 1000 },
|
|
757
|
-
{ path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: (base + 200) * 1000 },
|
|
756
|
+
{ path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: new Date((base + 300) * 1000) },
|
|
757
|
+
{ path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: new Date((base + 200) * 1000) },
|
|
758
758
|
]
|
|
759
759
|
: [],
|
|
760
760
|
)
|
|
@@ -22,7 +22,7 @@ export const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
|
|
|
22
22
|
* 这类用例遍历本机 ~/.pi/agent 全量会话语料(实测 4600+ 文件),空载单用例已需 6-10s;
|
|
23
23
|
* 38 包并行全量测试时 CPU/IO 争抢可降速 3-4 倍,30s 标定必假红。90s ≈ 空载 9 倍余量,
|
|
24
24
|
* 仍能兜住真正的挂死。仅用于遍历全量语料的用例(findSessions / buildFamilyFromFs /
|
|
25
|
-
*
|
|
25
|
+
* resolveSessionRoots 等);读已知单文件的用例不需要放宽。
|
|
26
26
|
*/
|
|
27
27
|
export const REAL_DATA_TIMEOUT_MS = 90_000
|
|
28
28
|
|
|
@@ -90,6 +90,8 @@ describe('renderOutline', () => {
|
|
|
90
90
|
// thinking 'secret'(6B) + toolResult 'output'(6B)
|
|
91
91
|
expect(b.omittedBytes).toBe(12)
|
|
92
92
|
expect(result.truncated).toBeUndefined()
|
|
93
|
+
// E7 行渲染统一:lines 随结果返回,与 turns 一一对应(预算度量 = 展示行同一份)
|
|
94
|
+
expect(result.lines).toEqual(['T000 · do something · bash×2,read×2 · → sure · [12B omitted]'])
|
|
93
95
|
})
|
|
94
96
|
|
|
95
97
|
it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
|
|
@@ -108,6 +110,25 @@ describe('renderOutline', () => {
|
|
|
108
110
|
expect(b.toolSummary).toBe('') // 被砍(降级)
|
|
109
111
|
expect(b.userBrief).toBe('u'.repeat(10)) // 骨架保留
|
|
110
112
|
}
|
|
113
|
+
// E7:降级后的渲染行(骨架形态)与 briefs 字段同步——行内无 → 结论段、无工具聚合段
|
|
114
|
+
expect(result.lines).toHaveLength(3)
|
|
115
|
+
expect(result.lines).toEqual(['T000 · uuuuuuuuuu', 'T001 · uuuuuuuuuu', 'T002 · uuuuuuuuuu'])
|
|
116
|
+
expect(result.truncated).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('2b. 降级(砍 assistantBrief 档):行内 → 结论段消失、toolSummary 保留,与改前语义一致', () => {
|
|
120
|
+
// budget=20 → perTurnCharBudget=80;level 0 行 = T000 · u*10 · bash×2 · → a*80… ≈112 > 80
|
|
121
|
+
// → 砍 assistantBrief 后 ≈26 ≤ 80 停(S3:该档形态与改前 handler 侧渲染降级后 briefs 等价)
|
|
122
|
+
const t = turn(
|
|
123
|
+
0,
|
|
124
|
+
[uEntry('U', 'u'.repeat(10)), aEntry('A', 'a'.repeat(100), [{ name: 'bash' }, { name: 'bash' }])],
|
|
125
|
+
{ userEntry: uEntry('U', 'u'.repeat(10)) },
|
|
126
|
+
)
|
|
127
|
+
const result = renderOutline([t], emptyTree(), { budget: 20 })
|
|
128
|
+
|
|
129
|
+
expect(result.turns[0].assistantBrief).toBe('') // 被砍
|
|
130
|
+
expect(result.turns[0].toolSummary).toBe('bash×2') // 保留
|
|
131
|
+
expect(result.lines).toEqual(['T000 · uuuuuuuuuu · bash×2'])
|
|
111
132
|
expect(result.truncated).toBeUndefined()
|
|
112
133
|
})
|
|
113
134
|
|
|
@@ -122,6 +143,8 @@ describe('renderOutline', () => {
|
|
|
122
143
|
|
|
123
144
|
expect(result.truncated).toBe(3)
|
|
124
145
|
expect(result.turns).toHaveLength(2)
|
|
146
|
+
// E7:lines 与 turns 同步截短(truncateToTotalBudget 双数组一致)
|
|
147
|
+
expect(result.lines).toHaveLength(2)
|
|
125
148
|
})
|
|
126
149
|
|
|
127
150
|
it('4. toolSummary 聚合:同名计数 ×N,不同名逗号分隔', () => {
|
|
@@ -168,6 +191,19 @@ describe('renderOutline', () => {
|
|
|
168
191
|
expect(withoutBranches.turns[0].branch).toBeUndefined()
|
|
169
192
|
})
|
|
170
193
|
|
|
194
|
+
it('7b. E7 旁支标记统一:渲染行 `[旁支 N entries]`(N = 该 forkPoint 下旁支子树 entry 数)', () => {
|
|
195
|
+
// branches: U → 3 entries 旁支子树(E7/D3:outline 与 export 两条路径共用此形态)
|
|
196
|
+
const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
|
|
197
|
+
const userEntry = uEntry('U', 'hi')
|
|
198
|
+
const turns = [turn(0, [userEntry], { userEntry })]
|
|
199
|
+
|
|
200
|
+
const result = renderOutline(turns, tree, { allBranches: true })
|
|
201
|
+
expect(result.lines).toEqual(['T000 · hi · [旁支 3 entries]'])
|
|
202
|
+
// branchSize 缺失(allBranches:false)不标旁支
|
|
203
|
+
const noBranch = renderOutline(turns, tree, { allBranches: false })
|
|
204
|
+
expect(noBranch.lines).toEqual(['T000 · hi'])
|
|
205
|
+
})
|
|
206
|
+
|
|
171
207
|
it.skipIf(!HAS_REAL_SESSION)('8. 真实 019e6c96:outline tokenEstimate <= 1500 + assistantBrief/toolSummary 非空(v2 O1)', async () => {
|
|
172
208
|
// v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 变长,阈值 600→1500(design §3.3 D4)
|
|
173
209
|
const parsed = await parseSessionFile(REAL_SESSION)
|
|
@@ -310,7 +346,6 @@ describe('renderDetail', () => {
|
|
|
310
346
|
expect(summary.summary).toMatch(/^read: f\.ts \(\d+KB\)$/)
|
|
311
347
|
expect(summary.totalLines).toBe(4)
|
|
312
348
|
expect(summary.headLines).toContain('line1')
|
|
313
|
-
expect(summary.fullEntry.message?.role).toBe('toolResult')
|
|
314
349
|
|
|
315
350
|
// includeToolResult 全文:返回原 Entry
|
|
316
351
|
const full = renderDetail([t], { includeToolResult: true })
|
|
@@ -2,11 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
2
2
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
extractFinalAssistantText,
|
|
8
|
-
type SessionReadParams,
|
|
9
|
-
} from '../tool-handler.js'
|
|
5
|
+
import { handleSessionRead, type SessionReadParams } from '../tool-handler.js'
|
|
6
|
+
import { extractFinalAssistantText } from '../result-action.js'
|
|
10
7
|
|
|
11
8
|
/**
|
|
12
9
|
* U6 result action 测试(design subagent-sync-collect §3.1.3 + impl-plan U6)。
|
|
@@ -152,7 +149,7 @@ describe('result action(U6)', () => {
|
|
|
152
149
|
assistantEntry(`${realId}-a2`, [{ type: 'text', text: 'second part' }]),
|
|
153
150
|
],
|
|
154
151
|
})
|
|
155
|
-
const r = await handleSessionRead({ action: 'result', session: 'sa-one' }, dir)
|
|
152
|
+
const r = await handleSessionRead({ action: 'result', session: 'sa-one' }, { agentDir: dir })
|
|
156
153
|
// 与 record.result 同源:全部 assistant 正文非空过滤 '\n\n' join,无任何包装
|
|
157
154
|
expect(r.content[0]?.text).toBe('FINAL RESULT\n\nsecond part')
|
|
158
155
|
const d = r.details as {
|
|
@@ -184,7 +181,7 @@ describe('result action(U6)', () => {
|
|
|
184
181
|
}
|
|
185
182
|
const r = await handleSessionRead(
|
|
186
183
|
{ action: 'result', session: 'sa-b1,sa-b2,sa-b3' },
|
|
187
|
-
dir,
|
|
184
|
+
{ agentDir: dir },
|
|
188
185
|
)
|
|
189
186
|
const text = r.content[0]?.text ?? ''
|
|
190
187
|
expect(text).toContain('[1/3] sa-b1')
|
|
@@ -207,10 +204,10 @@ describe('result action(U6)', () => {
|
|
|
207
204
|
it('3. 批量 >10:11 id 拒绝,报错含上限 10 与分批指引(解析先于 fs,无需 fixture)', async () => {
|
|
208
205
|
const list = Array.from({ length: 11 }, (_, i) => `sa-x${i}`).join(',')
|
|
209
206
|
await expect(
|
|
210
|
-
handleSessionRead({ action: 'result', session: list }, dir),
|
|
207
|
+
handleSessionRead({ action: 'result', session: list }, { agentDir: dir }),
|
|
211
208
|
).rejects.toThrow(/最多 10 个/)
|
|
212
209
|
try {
|
|
213
|
-
await handleSessionRead({ action: 'result', session: list }, dir)
|
|
210
|
+
await handleSessionRead({ action: 'result', session: list }, { agentDir: dir })
|
|
214
211
|
} catch (e) {
|
|
215
212
|
const msg = (e as Error).message
|
|
216
213
|
expect(msg).toContain('11')
|
|
@@ -226,7 +223,7 @@ describe('result action(U6)', () => {
|
|
|
226
223
|
realId,
|
|
227
224
|
entries: [assistantEntry(`${realId}-a1`, [{ type: 'text', text: longText }])],
|
|
228
225
|
})
|
|
229
|
-
const r = await handleSessionRead({ action: 'result', session: 'sa-long' }, dir)
|
|
226
|
+
const r = await handleSessionRead({ action: 'result', session: 'sa-long' }, { agentDir: dir })
|
|
230
227
|
const text = r.content[0]?.text ?? ''
|
|
231
228
|
// 截断到 8000:第 8001 个 x 不在正文,但提示行给出计数
|
|
232
229
|
expect(text.startsWith('x'.repeat(8000))).toBe(true)
|
|
@@ -249,7 +246,7 @@ describe('result action(U6)', () => {
|
|
|
249
246
|
})
|
|
250
247
|
const cut = await handleSessionRead(
|
|
251
248
|
{ action: 'result', session: 'sa-lim', limit: 5 },
|
|
252
|
-
dir,
|
|
249
|
+
{ agentDir: dir },
|
|
253
250
|
)
|
|
254
251
|
expect(cut.content[0]?.text).toBe(
|
|
255
252
|
'abcde\n\n[truncated 5 of 10 chars — full text: read ' +
|
|
@@ -258,31 +255,31 @@ describe('result action(U6)', () => {
|
|
|
258
255
|
)
|
|
259
256
|
const exact = await handleSessionRead(
|
|
260
257
|
{ action: 'result', session: 'sa-lim', limit: 10 },
|
|
261
|
-
dir,
|
|
258
|
+
{ agentDir: dir },
|
|
262
259
|
)
|
|
263
260
|
expect(exact.content[0]?.text).toBe('abcdefghij')
|
|
264
261
|
const d = exact.details as { truncated: boolean }
|
|
265
262
|
expect(d.truncated).toBe(false)
|
|
266
263
|
for (const bad of [0, -1, Number.NaN]) {
|
|
267
264
|
await expect(
|
|
268
|
-
handleSessionRead({ action: 'result', session: 'sa-lim', limit: bad }, dir),
|
|
265
|
+
handleSessionRead({ action: 'result', session: 'sa-lim', limit: bad }, { agentDir: dir }),
|
|
269
266
|
).rejects.toThrow(/limit.*无效/)
|
|
270
267
|
}
|
|
271
268
|
})
|
|
272
269
|
|
|
273
270
|
it('6. 不存在 id:sa-id 无 manifest → 无匹配 record;uuid 片段零匹配 → 无匹配 session', async () => {
|
|
274
271
|
await expect(
|
|
275
|
-
handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, dir),
|
|
272
|
+
handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir }),
|
|
276
273
|
).rejects.toThrow(/无匹配 record/)
|
|
277
274
|
try {
|
|
278
|
-
await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, dir)
|
|
275
|
+
await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir })
|
|
279
276
|
} catch (e) {
|
|
280
277
|
const msg = (e as Error).message
|
|
281
278
|
expect(msg).toContain('👉')
|
|
282
279
|
expect(msg).toContain('family')
|
|
283
280
|
}
|
|
284
281
|
await expect(
|
|
285
|
-
handleSessionRead({ action: 'result', session: 'zzz-nonexistent-9q8x2' }, dir),
|
|
282
|
+
handleSessionRead({ action: 'result', session: 'zzz-nonexistent-9q8x2' }, { agentDir: dir }),
|
|
286
283
|
).rejects.toThrow(/无匹配 session/)
|
|
287
284
|
})
|
|
288
285
|
|
|
@@ -293,10 +290,10 @@ describe('result action(U6)', () => {
|
|
|
293
290
|
sessionFileExists: false,
|
|
294
291
|
})
|
|
295
292
|
await expect(
|
|
296
|
-
handleSessionRead({ action: 'result', session: 'sa-flush' }, dir),
|
|
293
|
+
handleSessionRead({ action: 'result', session: 'sa-flush' }, { agentDir: dir }),
|
|
297
294
|
).rejects.toThrow(/session 文件不存在/)
|
|
298
295
|
try {
|
|
299
|
-
await handleSessionRead({ action: 'result', session: 'sa-flush' }, dir)
|
|
296
|
+
await handleSessionRead({ action: 'result', session: 'sa-flush' }, { agentDir: dir })
|
|
300
297
|
} catch (e) {
|
|
301
298
|
const msg = (e as Error).message
|
|
302
299
|
expect(msg).toContain('未写入')
|
|
@@ -310,10 +307,10 @@ describe('result action(U6)', () => {
|
|
|
310
307
|
realId: '019e6c96-0000-0000-0000-000000000a01',
|
|
311
308
|
})
|
|
312
309
|
await expect(
|
|
313
|
-
handleSessionRead({ action: 'result', session: 'sa-empty' }, dir),
|
|
310
|
+
handleSessionRead({ action: 'result', session: 'sa-empty' }, { agentDir: dir }),
|
|
314
311
|
).rejects.toThrow(/尚无 assistant 输出/)
|
|
315
312
|
try {
|
|
316
|
-
await handleSessionRead({ action: 'result', session: 'sa-empty' }, dir)
|
|
313
|
+
await handleSessionRead({ action: 'result', session: 'sa-empty' }, { agentDir: dir })
|
|
317
314
|
} catch (e) {
|
|
318
315
|
const msg = (e as Error).message
|
|
319
316
|
expect(msg).toContain('sa-empty')
|
|
@@ -323,10 +320,10 @@ describe('result action(U6)', () => {
|
|
|
323
320
|
|
|
324
321
|
it('9. 参数防御:缺 session / 空条目(尾逗号)均报错', async () => {
|
|
325
322
|
await expect(
|
|
326
|
-
handleSessionRead({ action: 'result' } as SessionReadParams, dir),
|
|
323
|
+
handleSessionRead({ action: 'result' } as SessionReadParams, { agentDir: dir }),
|
|
327
324
|
).rejects.toThrow(/需要参数 "session"/)
|
|
328
325
|
await expect(
|
|
329
|
-
handleSessionRead({ action: 'result', session: 'sa-a,' }, dir),
|
|
326
|
+
handleSessionRead({ action: 'result', session: 'sa-a,' }, { agentDir: dir }),
|
|
330
327
|
).rejects.toThrow(/空条目/)
|
|
331
328
|
})
|
|
332
329
|
|
|
@@ -334,7 +331,7 @@ describe('result action(U6)', () => {
|
|
|
334
331
|
await expect(
|
|
335
332
|
handleSessionRead(
|
|
336
333
|
{ action: 'bogus' } as unknown as SessionReadParams,
|
|
337
|
-
dir,
|
|
334
|
+
{ agentDir: dir },
|
|
338
335
|
),
|
|
339
336
|
).rejects.toThrow(/find\/family\/outline\/expand\/detail\/search\/export\/extract\/workflow\/result\/doctor/)
|
|
340
337
|
})
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { mkdtemp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
|
|
4
4
|
import { join, dirname } from 'node:path'
|
|
5
5
|
import {
|
|
6
|
-
listMainSessions,
|
|
7
|
-
listSubagentSessions,
|
|
8
6
|
resolveSessionRoots,
|
|
9
7
|
normalizeLiveSessionDir,
|
|
10
8
|
type SessionRoot,
|
|
@@ -13,6 +11,24 @@ import {
|
|
|
13
11
|
import { buildFamilyFromFs } from '../discovery/subagents.js'
|
|
14
12
|
import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_AGENT_DIR, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
|
|
15
13
|
|
|
14
|
+
// ============================================================
|
|
15
|
+
// A3(ext-simplify-04 U4):listMainSessions/listSubagentSessions 薄包装已删除,
|
|
16
|
+
// subagents.ts 直调 resolveSessionRoots+filter。以下 describe 用同一等价式作入口,
|
|
17
|
+
// 保过滤语义覆盖(.finalized 排除 / workflow-state 跳过 / 递归 / 根选择 / 空目录容错)。
|
|
18
|
+
// ============================================================
|
|
19
|
+
|
|
20
|
+
/** 原 listMainSessions 等价式:agentDir 信号包下未去重 main 根的文件并集 */
|
|
21
|
+
async function mainSessionFiles(agentDir: string) {
|
|
22
|
+
return (await resolveSessionRoots({ agentDir }))
|
|
23
|
+
.filter((r) => r.source === 'main' && r.dedupedInto === undefined)
|
|
24
|
+
.flatMap((r) => r.files)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 原 listSubagentSessions 等价式:agentDir 信号包下 [subagent] 根文件集 */
|
|
28
|
+
async function subagentSessionFiles(agentDir: string) {
|
|
29
|
+
return (await resolveSessionRoots({ agentDir })).find((r) => r.kind === 'subagent')?.files ?? []
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
/** 写 .jsonl fixture(父目录不存在则自建),内容默认合法 session header */
|
|
17
33
|
async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n') {
|
|
18
34
|
await mkdir(dirname(path), { recursive: true })
|
|
@@ -22,7 +38,7 @@ async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n
|
|
|
22
38
|
/** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾,实证见 real-data.ts) */
|
|
23
39
|
const SLUG = '--Users-foo--'
|
|
24
40
|
|
|
25
|
-
describe('listMainSessions', () => {
|
|
41
|
+
describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆盖)', () => {
|
|
26
42
|
let dir: string
|
|
27
43
|
|
|
28
44
|
beforeEach(async () => {
|
|
@@ -38,7 +54,7 @@ describe('listMainSessions', () => {
|
|
|
38
54
|
await writeFile(join(dir, 'sessions', slug, 'a.jsonl'), '{"type":"session","id":"a"}\n')
|
|
39
55
|
await writeFile(join(dir, 'sessions', slug, 'b.jsonl.finalized'), '{"type":"session","id":"b"}\n')
|
|
40
56
|
|
|
41
|
-
const result = await
|
|
57
|
+
const result = await mainSessionFiles(dir)
|
|
42
58
|
expect(result).toHaveLength(1)
|
|
43
59
|
expect(result[0].path.endsWith('a.jsonl')).toBe(true)
|
|
44
60
|
expect(result[0].path.endsWith('.finalized')).toBe(false)
|
|
@@ -54,7 +70,7 @@ describe('listMainSessions', () => {
|
|
|
54
70
|
// 同 slug 直接层也放一个,验证同一 slug 下平铺与嵌套并存
|
|
55
71
|
await writeFile(join(dir, 'sessions', 'slug', 'top.jsonl'), '{}\n')
|
|
56
72
|
|
|
57
|
-
const result = await
|
|
73
|
+
const result = await mainSessionFiles(dir)
|
|
58
74
|
const names = result.map((m) => m.path.split('/').pop()!)
|
|
59
75
|
expect(names).toContain('d.jsonl')
|
|
60
76
|
expect(names).toContain('top.jsonl')
|
|
@@ -70,22 +86,22 @@ describe('listMainSessions', () => {
|
|
|
70
86
|
'{"v":"wf-run-v1"}\n',
|
|
71
87
|
)
|
|
72
88
|
|
|
73
|
-
const result = await
|
|
89
|
+
const result = await mainSessionFiles(dir)
|
|
74
90
|
expect(result).toHaveLength(1)
|
|
75
91
|
expect(result[0].path.endsWith('real.jsonl')).toBe(true)
|
|
76
92
|
expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
|
|
77
93
|
})
|
|
78
94
|
|
|
79
95
|
it('空 agentDir(无 sessions 目录)返回 [],不抛错', async () => {
|
|
80
|
-
await expect(
|
|
96
|
+
await expect(mainSessionFiles(dir)).resolves.toEqual([])
|
|
81
97
|
})
|
|
82
98
|
|
|
83
99
|
it('不存在的 agentDir 返回 [],不抛错', async () => {
|
|
84
|
-
await expect(
|
|
100
|
+
await expect(mainSessionFiles(join(dir, 'no-such-dir'))).resolves.toEqual([])
|
|
85
101
|
})
|
|
86
102
|
|
|
87
103
|
it.skipIf(!HAS_E6)('真实数据:扫描 ~/.pi/agent,含 019e6c96,不含 .finalized 与 wf-', async () => {
|
|
88
|
-
const result = await
|
|
104
|
+
const result = await mainSessionFiles(REAL_AGENT_DIR)
|
|
89
105
|
expect(result.length).toBeGreaterThan(0)
|
|
90
106
|
// 含目标 session
|
|
91
107
|
expect(result.some((m) => m.path.includes('019e6c96'))).toBe(true)
|
|
@@ -100,7 +116,7 @@ describe('listMainSessions', () => {
|
|
|
100
116
|
}, REAL_DATA_TIMEOUT_MS)
|
|
101
117
|
})
|
|
102
118
|
|
|
103
|
-
describe('listSubagentSessions', () => {
|
|
119
|
+
describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后保覆盖)', () => {
|
|
104
120
|
let dir: string
|
|
105
121
|
|
|
106
122
|
beforeEach(async () => {
|
|
@@ -119,7 +135,7 @@ describe('listSubagentSessions', () => {
|
|
|
119
135
|
'{"type":"session"}\n',
|
|
120
136
|
)
|
|
121
137
|
|
|
122
|
-
const result = await
|
|
138
|
+
const result = await subagentSessionFiles(dir)
|
|
123
139
|
expect(result).toHaveLength(1)
|
|
124
140
|
expect(result[0].path.endsWith('c.jsonl')).toBe(true)
|
|
125
141
|
expect(result[0].path.endsWith('.finalized')).toBe(false)
|
|
@@ -132,17 +148,17 @@ describe('listSubagentSessions', () => {
|
|
|
132
148
|
await writeFile(join(dir, 'subagents', slug, 'records', 'manifest.json'), '{}\n')
|
|
133
149
|
await writeFile(join(dir, 'subagents', slug, 'sessions', 'sub.jsonl'), '{"type":"session"}\n')
|
|
134
150
|
|
|
135
|
-
const result = await
|
|
151
|
+
const result = await subagentSessionFiles(dir)
|
|
136
152
|
expect(result).toHaveLength(1)
|
|
137
153
|
expect(result[0].path.endsWith('sub.jsonl')).toBe(true)
|
|
138
154
|
})
|
|
139
155
|
|
|
140
156
|
it('无 subagents 目录返回 [],不抛错', async () => {
|
|
141
|
-
await expect(
|
|
157
|
+
await expect(subagentSessionFiles(dir)).resolves.toEqual([])
|
|
142
158
|
})
|
|
143
159
|
|
|
144
160
|
it.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:扫描 ~/.pi/agent/subagents 返回非空', async () => {
|
|
145
|
-
const result = await
|
|
161
|
+
const result = await subagentSessionFiles(REAL_AGENT_DIR)
|
|
146
162
|
expect(result.length).toBeGreaterThan(0)
|
|
147
163
|
expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
|
|
148
164
|
}, REAL_DATA_TIMEOUT_MS)
|
|
@@ -364,11 +380,11 @@ describe('buildFamilyFromFs not-found 文案(U1 并入:列实际扫描候选
|
|
|
364
380
|
|
|
365
381
|
// ============================================================
|
|
366
382
|
// u8 追加:resolveSessionRoots options(doctor 统一数据源,design §6.3「同一数据源
|
|
367
|
-
// 两处渲染,不重复实现探测」——subagent
|
|
368
|
-
//
|
|
383
|
+
// 两处渲染,不重复实现探测」——subagent 扫描模式经 options 由调用方驱动;原 u8 的
|
|
384
|
+
// 注入式缓存面已删除,ext-simplify-04 U3,每次调用实扫)
|
|
369
385
|
// ============================================================
|
|
370
386
|
|
|
371
|
-
describe('resolveSessionRoots options(u8:subagents
|
|
387
|
+
describe('resolveSessionRoots options(u8:subagents 模式)', () => {
|
|
372
388
|
/** 最小 fixture:default 根 1 文件 + subagent 根 1 文件(legacy 不存在) */
|
|
373
389
|
async function buildMinimalFixture(): Promise<{ tmp: string; agentDir: string }> {
|
|
374
390
|
const tmp = await mkdtemp(join(tmpdir(), 'roots-opts-'))
|
|
@@ -413,71 +429,21 @@ describe('resolveSessionRoots options(u8:subagents 模式 + 注入式缓存
|
|
|
413
429
|
}
|
|
414
430
|
})
|
|
415
431
|
|
|
416
|
-
it('
|
|
432
|
+
it('重复调用每次实扫(无缓存):两次结果独立完整,files 恒非空实扫值', async () => {
|
|
433
|
+
// 原三个 cache 用例(set 回写 / 命中优先 / stat × cache)随缓存注入面删除
|
|
434
|
+
// (ext-simplify-04 U3);本用例钉死删除后契约——不存在任何缓存层
|
|
417
435
|
const { tmp, agentDir } = await buildMinimalFixture()
|
|
418
436
|
try {
|
|
419
|
-
const
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const firstDef = first.find((r) => r.kind === 'default')!
|
|
429
|
-
expect(firstDef.cached).toBeUndefined()
|
|
430
|
-
expect(firstDef.fileCount).toBe(1)
|
|
431
|
-
expect(getSpy).toHaveBeenCalled()
|
|
432
|
-
expect(setSpy).toHaveBeenCalledTimes(3)
|
|
433
|
-
|
|
434
|
-
// 二扫:命中 → 缓存值即数据源,无新回写
|
|
435
|
-
const second = await resolveSessionRoots({ agentDir }, { cache })
|
|
436
|
-
const secondDef = second.find((r) => r.kind === 'default')!
|
|
437
|
-
expect(secondDef.cached).toBe(true)
|
|
438
|
-
expect(secondDef.fileCount).toBe(1)
|
|
439
|
-
expect(secondDef.files).toEqual([])
|
|
440
|
-
expect(setSpy).toHaveBeenCalledTimes(3)
|
|
441
|
-
} finally {
|
|
442
|
-
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
443
|
-
}
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
it('cache 命中值优先于磁盘(roots 不旁路缓存实扫——TTL/mtime 失效语义全在缓存实现侧)', async () => {
|
|
447
|
-
const { tmp, agentDir } = await buildMinimalFixture()
|
|
448
|
-
try {
|
|
449
|
-
const setSpy = vi.fn()
|
|
450
|
-
const cache = {
|
|
451
|
-
get: async () => ({ exists: true, fileCount: 99, scanMs: 7 }),
|
|
452
|
-
set: setSpy,
|
|
437
|
+
const first = await resolveSessionRoots({ agentDir })
|
|
438
|
+
const second = await resolveSessionRoots({ agentDir })
|
|
439
|
+
for (const roots of [first, second]) {
|
|
440
|
+
const def = roots.find((r) => r.kind === 'default')!
|
|
441
|
+
expect(def.fileCount).toBe(1)
|
|
442
|
+
expect(def.files).toHaveLength(1)
|
|
443
|
+
const sub = roots.find((r) => r.kind === 'subagent')!
|
|
444
|
+
expect(sub.fileCount).toBe(1)
|
|
445
|
+
expect(sub.files).toHaveLength(1)
|
|
453
446
|
}
|
|
454
|
-
const roots = await resolveSessionRoots({ agentDir }, { cache })
|
|
455
|
-
const def = roots.find((r) => r.kind === 'default')!
|
|
456
|
-
expect(def.fileCount).toBe(99)
|
|
457
|
-
expect(def.scanMs).toBe(7)
|
|
458
|
-
expect(def.cached).toBe(true)
|
|
459
|
-
expect(def.files).toEqual([])
|
|
460
|
-
expect(setSpy).not.toHaveBeenCalled() // 命中不回写
|
|
461
|
-
} finally {
|
|
462
|
-
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
463
|
-
}
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it("stat × cache 组合:'stat' 的 subagent 根不产缓存条目(缓存只服务扫盘贵的根)", async () => {
|
|
467
|
-
const { tmp, agentDir } = await buildMinimalFixture()
|
|
468
|
-
try {
|
|
469
|
-
const setKeys: string[] = []
|
|
470
|
-
await resolveSessionRoots({ agentDir }, {
|
|
471
|
-
subagents: 'stat',
|
|
472
|
-
cache: {
|
|
473
|
-
get: async () => undefined,
|
|
474
|
-
set: (key) => {
|
|
475
|
-
setKeys.push(key)
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
})
|
|
479
|
-
expect(setKeys).not.toContain(join(agentDir, 'subagents'))
|
|
480
|
-
expect(setKeys).toContain(join(agentDir, 'sessions'))
|
|
481
447
|
} finally {
|
|
482
448
|
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
483
449
|
}
|
|
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { createSessionCommand } from '../tui/session-command.js'
|
|
6
|
+
import { SessionManager } from '@earendil-works/pi-coding-agent'
|
|
6
7
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -117,6 +118,16 @@ describe('createSessionCommand - getArgumentCompletions', () => {
|
|
|
117
118
|
const cmd = createSessionCommand(() => cwdSessionDir)
|
|
118
119
|
expect(await cmd.getArgumentCompletions('deadbeef')).toBeNull()
|
|
119
120
|
})
|
|
121
|
+
|
|
122
|
+
it('空 getCwdSessionDir → 秒回 null,不调 listAll(A1:无 live session 上下文不触发全盘分支)', async () => {
|
|
123
|
+
// mock 拦断言用:guard 失效误调 listAll('') 时 pi 会走真实全盘扫描(实测 3488 项 / ~8s),
|
|
124
|
+
// mock 掉保证失败态也是秒级红灯
|
|
125
|
+
const listAllSpy = vi.spyOn(SessionManager, 'listAll').mockResolvedValue([])
|
|
126
|
+
const cmd = createSessionCommand(() => '')
|
|
127
|
+
expect(await cmd.getArgumentCompletions('')).toBeNull()
|
|
128
|
+
expect(listAllSpy).not.toHaveBeenCalled()
|
|
129
|
+
listAllSpy.mockRestore()
|
|
130
|
+
})
|
|
120
131
|
})
|
|
121
132
|
|
|
122
133
|
describe('createSessionCommand - handler select 流程', () => {
|
|
@@ -175,6 +186,18 @@ describe('createSessionCommand - handler select 流程', () => {
|
|
|
175
186
|
expect(setEditorText).not.toHaveBeenCalled()
|
|
176
187
|
})
|
|
177
188
|
|
|
189
|
+
it('空 getCwdSessionDir → 按零匹配 notify,不调 listAll(A1:handler 侧同款空串 guard)', async () => {
|
|
190
|
+
const listAllSpy = vi.spyOn(SessionManager, 'listAll').mockResolvedValue([])
|
|
191
|
+
const cmd = createSessionCommand(() => '')
|
|
192
|
+
const { ctx, select, notify, setEditorText } = makeFakeCtx()
|
|
193
|
+
await cmd.handler('', ctx)
|
|
194
|
+
expect(notify).toHaveBeenCalledWith('未找到匹配的 session。', 'warning')
|
|
195
|
+
expect(select).not.toHaveBeenCalled()
|
|
196
|
+
expect(setEditorText).not.toHaveBeenCalled()
|
|
197
|
+
expect(listAllSpy).not.toHaveBeenCalled()
|
|
198
|
+
listAllSpy.mockRestore()
|
|
199
|
+
})
|
|
200
|
+
|
|
178
201
|
it('MF-2 回归:同 cwd 同预览同 age 桶 → label 带短 uuid 后缀消歧,选第 2 条插第 2 条 uuid', async () => {
|
|
179
202
|
// 两 session:相同首消息 + 相同 timestamp(同 age 桶)→ toCandidate label 完全相同
|
|
180
203
|
const ID1 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
|
|
@@ -228,9 +228,10 @@ describe('buildFamilyFromFs - fixture', () => {
|
|
|
228
228
|
expect(sub!.rootSessionId).toBe(FORK)
|
|
229
229
|
expect(sub!.slug).toBe('test-sub')
|
|
230
230
|
expect(sub!.cleanedUp).toBe(false)
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
expect(sub!.
|
|
231
|
+
// enrichRefs 已删除(ext-simplify-04 U4/E2):family 路径 fileName/cwd 维持 core 占位空串
|
|
232
|
+
//(SessionRef 与 find 路径共享,find 侧有真实消费者;workflow calls 反查走 pathToRef 不受影响)
|
|
233
|
+
expect(sub!.fileName).toBe('')
|
|
234
|
+
expect(sub!.cwd).toBe('')
|
|
234
235
|
})
|
|
235
236
|
|
|
236
237
|
it('从 fork 子代 resolve 也能关联到挂在其下的 subagent', async () => {
|