@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
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
-
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, utimes, readFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir, homedir } from 'node:os'
|
|
5
5
|
import { join, dirname } from 'node:path'
|
|
6
6
|
import {
|
|
7
7
|
handleSessionRead,
|
|
8
|
-
levenshtein,
|
|
9
|
-
renderExtractItems,
|
|
10
|
-
DOCTOR_CACHE_TTL_MS,
|
|
11
8
|
METADATA_CACHE_TTL_MS,
|
|
12
|
-
MULTI_SEARCH_MAX_SESSIONS,
|
|
13
|
-
SEARCH_SCAN_BYTE_BUDGET,
|
|
14
|
-
searchAcrossSessions,
|
|
15
9
|
type SessionReadParams,
|
|
16
10
|
type SessionReadSignals,
|
|
17
11
|
} from '../tool-handler.js'
|
|
12
|
+
import { type DoctorDetails } from '../doctor.js'
|
|
13
|
+
import { levenshtein } from '../no-match.js'
|
|
14
|
+
import {
|
|
15
|
+
MULTI_SEARCH_MAX_SESSIONS,
|
|
16
|
+
SEARCH_SCAN_BYTE_BUDGET,
|
|
17
|
+
searchAcrossSessions,
|
|
18
|
+
} from '../search-across.js'
|
|
19
|
+
import { renderExtractItems } from '../extract.js'
|
|
18
20
|
import { listRecordManifests } from '../discovery/subagents.js'
|
|
19
21
|
import type { SessionMetadataProvider } from '../discovery/find.js'
|
|
20
|
-
import type { DoctorDetails } from '../doctor.js'
|
|
21
22
|
import {
|
|
22
23
|
REAL_AGENT_DIR as REAL,
|
|
23
24
|
E6,
|
|
@@ -46,14 +47,14 @@ import {
|
|
|
46
47
|
// vitest 默认 5s(pnpm extensions:test 全量跑时多包集成测试并发 IO),显式放宽到 60s。
|
|
47
48
|
describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
48
49
|
it('1. find by uuid fragment returns matching session', async () => {
|
|
49
|
-
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, REAL)
|
|
50
|
+
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir: REAL })
|
|
50
51
|
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
51
52
|
expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
|
|
52
53
|
expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
|
|
53
54
|
})
|
|
54
55
|
|
|
55
56
|
it('2. outline yields 32 turns within token budget (v2 O1: <=1500)', async () => {
|
|
56
|
-
const r = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
|
|
57
|
+
const r = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
|
|
57
58
|
const d = r.details as { turns: unknown[]; tokenEstimate: number }
|
|
58
59
|
expect(d.turns.length).toBe(32)
|
|
59
60
|
// v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 600→1500(design §3.3 D4)
|
|
@@ -61,7 +62,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
61
62
|
})
|
|
62
63
|
|
|
63
64
|
it('3. detail single turn returns toolResult summary by default (v2 O3)', async () => {
|
|
64
|
-
const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, REAL)
|
|
65
|
+
const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, { agentDir: REAL })
|
|
65
66
|
const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
|
|
66
67
|
expect(d.entries.length).toBeGreaterThan(0)
|
|
67
68
|
// v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
|
|
@@ -76,7 +77,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
76
77
|
// 而非失败——与 TC14-TC18 的「数据不存在则 return」守卫模式一致,避免偶发红。
|
|
77
78
|
// 注意必须用 hasAnyRealSession(双目录),hasRealSession 只扫 sessions/ 扫不到 019fe635。
|
|
78
79
|
if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
|
|
79
|
-
const r = await handleSessionRead({ action: 'family', session: FAM }, REAL)
|
|
80
|
+
const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: REAL })
|
|
80
81
|
const d = r.details as {
|
|
81
82
|
forks: Array<{ sessionId: string }>
|
|
82
83
|
subagents: Array<{ sessionId: string }>
|
|
@@ -87,7 +88,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
87
88
|
})
|
|
88
89
|
|
|
89
90
|
it('5. search pattern returns hits', async () => {
|
|
90
|
-
const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, REAL)
|
|
91
|
+
const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, { agentDir: REAL })
|
|
91
92
|
const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
|
|
92
93
|
expect(d.hits.length).toBeGreaterThan(0)
|
|
93
94
|
expect(typeof d.hits[0].matchSnippet).toBe('string')
|
|
@@ -96,7 +97,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
96
97
|
it('6. export outline materializes a .md file', async () => {
|
|
97
98
|
const r = await handleSessionRead(
|
|
98
99
|
{ action: 'export', session: E6, format: 'outline' },
|
|
99
|
-
REAL,
|
|
100
|
+
{ agentDir: REAL },
|
|
100
101
|
)
|
|
101
102
|
const d = r.details as { path: string; sizeBytes: number }
|
|
102
103
|
expect(d.path).toMatch(/\.md$/)
|
|
@@ -111,7 +112,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
111
112
|
it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
|
|
112
113
|
const r = await handleSessionRead(
|
|
113
114
|
{ action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
|
|
114
|
-
REAL,
|
|
115
|
+
{ agentDir: REAL },
|
|
115
116
|
)
|
|
116
117
|
const d = r.details as { matches: unknown[]; truncated: boolean }
|
|
117
118
|
expect(d.matches).toEqual([])
|
|
@@ -125,17 +126,17 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
125
126
|
|
|
126
127
|
it('8. F4 detail turn out of range throws with 越界', async () => {
|
|
127
128
|
await expect(
|
|
128
|
-
handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, REAL),
|
|
129
|
+
handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, { agentDir: REAL }),
|
|
129
130
|
).rejects.toThrow(/越界/)
|
|
130
131
|
})
|
|
131
132
|
|
|
132
133
|
it('9. F5 outline missing session throws naming session', async () => {
|
|
133
|
-
await expect(handleSessionRead({ action: 'outline' }, REAL)).rejects.toThrow(/session/)
|
|
134
|
+
await expect(handleSessionRead({ action: 'outline' }, { agentDir: REAL })).rejects.toThrow(/session/)
|
|
134
135
|
})
|
|
135
136
|
|
|
136
137
|
it('10. resolveSessionId fragment equivalent to full id', async () => {
|
|
137
|
-
const rFull = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
|
|
138
|
-
const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, REAL)
|
|
138
|
+
const rFull = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
|
|
139
|
+
const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: REAL })
|
|
139
140
|
const dFull = rFull.details as { turns: unknown[] }
|
|
140
141
|
const dFrag = rFrag.details as { turns: unknown[] }
|
|
141
142
|
expect(dFrag.turns.length).toBe(dFull.turns.length)
|
|
@@ -146,7 +147,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
146
147
|
it('user-messages returns 26 user entries with turn + full text', async () => {
|
|
147
148
|
const r = await handleSessionRead(
|
|
148
149
|
{ action: 'extract', session: E6, what: 'user-messages' },
|
|
149
|
-
REAL,
|
|
150
|
+
{ agentDir: REAL },
|
|
150
151
|
)
|
|
151
152
|
const d = r.details as {
|
|
152
153
|
what: string
|
|
@@ -169,7 +170,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
169
170
|
it('commands (no filter) returns 519 tool calls with name + summary', async () => {
|
|
170
171
|
const r = await handleSessionRead(
|
|
171
172
|
{ action: 'extract', session: E6, what: 'commands' },
|
|
172
|
-
REAL,
|
|
173
|
+
{ agentDir: REAL },
|
|
173
174
|
)
|
|
174
175
|
const d = r.details as {
|
|
175
176
|
count: number
|
|
@@ -192,7 +193,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
192
193
|
it('commands tool=bash returns 309 all bash', async () => {
|
|
193
194
|
const r = await handleSessionRead(
|
|
194
195
|
{ action: 'extract', session: E6, what: 'commands', tool: 'bash' },
|
|
195
|
-
REAL,
|
|
196
|
+
{ agentDir: REAL },
|
|
196
197
|
)
|
|
197
198
|
const d = r.details as {
|
|
198
199
|
count: number
|
|
@@ -206,7 +207,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
206
207
|
it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
|
|
207
208
|
const r = await handleSessionRead(
|
|
208
209
|
{ action: 'extract', session: E6, what: 'commands', tool: 'nonexist' },
|
|
209
|
-
REAL,
|
|
210
|
+
{ agentDir: REAL },
|
|
210
211
|
)
|
|
211
212
|
const d = r.details as {
|
|
212
213
|
toolDistribution: Array<{ name: string; count: number }>
|
|
@@ -220,7 +221,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
220
221
|
it('files returns deduped paths with op (read/edit/write/head)', async () => {
|
|
221
222
|
const r = await handleSessionRead(
|
|
222
223
|
{ action: 'extract', session: E6, what: 'files' },
|
|
223
|
-
REAL,
|
|
224
|
+
{ agentDir: REAL },
|
|
224
225
|
)
|
|
225
226
|
const d = r.details as {
|
|
226
227
|
count: number
|
|
@@ -241,7 +242,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
241
242
|
it('commits returns hash list with 7-8 hex + source turn', async () => {
|
|
242
243
|
const r = await handleSessionRead(
|
|
243
244
|
{ action: 'extract', session: E6, what: 'commits' },
|
|
244
|
-
REAL,
|
|
245
|
+
{ agentDir: REAL },
|
|
245
246
|
)
|
|
246
247
|
const d = r.details as {
|
|
247
248
|
count: number
|
|
@@ -260,7 +261,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
260
261
|
it('tool-results (no filter) returns 515 results with toolName + text', async () => {
|
|
261
262
|
const r = await handleSessionRead(
|
|
262
263
|
{ action: 'extract', session: E6, what: 'tool-results' },
|
|
263
|
-
REAL,
|
|
264
|
+
{ agentDir: REAL },
|
|
264
265
|
)
|
|
265
266
|
const d = r.details as {
|
|
266
267
|
count: number
|
|
@@ -277,7 +278,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
277
278
|
it('tool-results tool=bash returns 309 all bash', async () => {
|
|
278
279
|
const r = await handleSessionRead(
|
|
279
280
|
{ action: 'extract', session: E6, what: 'tool-results', tool: 'bash' },
|
|
280
|
-
REAL,
|
|
281
|
+
{ agentDir: REAL },
|
|
281
282
|
)
|
|
282
283
|
const d = r.details as { count: number; items: Array<{ toolName: string }> }
|
|
283
284
|
expect(d.count).toBe(309)
|
|
@@ -287,7 +288,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
287
288
|
it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
|
|
288
289
|
const r = await handleSessionRead(
|
|
289
290
|
{ action: 'extract', session: E6, what: 'tool-results', tool: 'nonexist' },
|
|
290
|
-
REAL,
|
|
291
|
+
{ agentDir: REAL },
|
|
291
292
|
)
|
|
292
293
|
expect(r.content[0].text).toContain('无匹配')
|
|
293
294
|
expect(r.content[0].text).toContain('bash×309')
|
|
@@ -297,8 +298,8 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
297
298
|
// what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
|
|
298
299
|
// isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
|
|
299
300
|
const params: SessionReadParams = { action: 'extract', session: E6 }
|
|
300
|
-
await expect(handleSessionRead(params, REAL)).rejects.toThrow(/无效/)
|
|
301
|
-
await expect(handleSessionRead(params, REAL)).rejects.toThrow(
|
|
301
|
+
await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(/无效/)
|
|
302
|
+
await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(
|
|
302
303
|
/user-messages\/commands\/files\/commits\/tool-results/,
|
|
303
304
|
)
|
|
304
305
|
})
|
|
@@ -309,7 +310,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
309
310
|
try {
|
|
310
311
|
await handleSessionRead(
|
|
311
312
|
{ action: 'extract', session: E6, what: 'user-messages', turns: 'T999' },
|
|
312
|
-
REAL,
|
|
313
|
+
{ agentDir: REAL },
|
|
313
314
|
)
|
|
314
315
|
} catch (e) {
|
|
315
316
|
msg = (e as Error).message
|
|
@@ -358,7 +359,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
|
358
359
|
await makeFixtureSession(dir, ID2, '第二段内容')
|
|
359
360
|
|
|
360
361
|
// outline 走 resolveSessionId → 2 匹配 → F2 消歧(不抛错)
|
|
361
|
-
const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, dir)
|
|
362
|
+
const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, { agentDir: dir })
|
|
362
363
|
const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
|
|
363
364
|
expect(d.ambiguous).toBe(true)
|
|
364
365
|
expect(d.candidates).toHaveLength(2)
|
|
@@ -382,7 +383,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
|
382
383
|
if (action === 'expand') params.turn = 'T001'
|
|
383
384
|
if (action === 'search') params.pattern = 'x'
|
|
384
385
|
if (action === 'extract') params.what = 'user-messages'
|
|
385
|
-
const r = await handleSessionRead(params, dir)
|
|
386
|
+
const r = await handleSessionRead(params, { agentDir: dir })
|
|
386
387
|
const d = r.details as { ambiguous: boolean }
|
|
387
388
|
expect(d.ambiguous, `action=${action}`).toBe(true)
|
|
388
389
|
expect(r.content[0].text).toContain('👉')
|
|
@@ -417,7 +418,7 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
|
|
|
417
418
|
]
|
|
418
419
|
await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
|
|
419
420
|
|
|
420
|
-
const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
|
|
421
|
+
const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
|
|
421
422
|
const d = r.details as { stats: { skippedLines: number } }
|
|
422
423
|
expect(d.stats.skippedLines).toBe(1)
|
|
423
424
|
expect(r.content[0].text).toContain('1 skipped lines')
|
|
@@ -425,13 +426,84 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
|
|
|
425
426
|
|
|
426
427
|
it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
|
|
427
428
|
await makeFixtureSession(dir, SID, 'clean session')
|
|
428
|
-
const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
|
|
429
|
+
const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
|
|
429
430
|
const d = r.details as { stats: { skippedLines: number } }
|
|
430
431
|
expect(d.stats.skippedLines).toBe(0)
|
|
431
432
|
expect(r.content[0].text).not.toContain('skipped lines')
|
|
432
433
|
})
|
|
433
434
|
})
|
|
434
435
|
|
|
436
|
+
// ============================================================
|
|
437
|
+
// E7:行渲染统一(ext-simplify-04 U5,design D3)。outline 与 export {format:"outline"}
|
|
438
|
+
// 共用 renderOutline 返回的渲染行(OutlineResult.lines)+ 同一尾段拼装——预算度量 =
|
|
439
|
+
// 展示 by construction;旁支标记统一为 `[旁支 N entries]`(改前 handler 侧第二份行
|
|
440
|
+
// 格式知识输出裸 `[旁支]`,已随 formatOutlineText 删除)。
|
|
441
|
+
// ============================================================
|
|
442
|
+
describe('E7 行渲染统一(outline 与 export outline 同一渲染行)', () => {
|
|
443
|
+
let dir: string
|
|
444
|
+
const SID = '019e6c96-bbbb-cccc-dddd-00000000000e'
|
|
445
|
+
|
|
446
|
+
/** 写含 fork 旁支的 session:主链 R→m1(user)→m2(assistant),b1/b2 挂 m1 下成
|
|
447
|
+
* 旁支子树(buildTreeView:branches[m1]=2;segmentTurns:单 turn [m1, m2],
|
|
448
|
+
* 旁支 entry 不进 leaf 视图)。 */
|
|
449
|
+
async function writeForkSession(): Promise<void> {
|
|
450
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
451
|
+
const msg = (id: string, parentId: string, role: 'user' | 'assistant', text: string) =>
|
|
452
|
+
JSON.stringify({
|
|
453
|
+
type: 'message',
|
|
454
|
+
id: `${SID}-${id}`,
|
|
455
|
+
parentId,
|
|
456
|
+
message: { role, content: [{ type: 'text', text }] },
|
|
457
|
+
})
|
|
458
|
+
const lines = [
|
|
459
|
+
JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
|
|
460
|
+
msg('m1', SID, 'user', 'fork point turn'),
|
|
461
|
+
msg('b1', `${SID}-m1`, 'assistant', 'branch one'),
|
|
462
|
+
msg('b2', `${SID}-m1`, 'assistant', 'branch two'),
|
|
463
|
+
msg('m2', `${SID}-m1`, 'assistant', 'main line answer'),
|
|
464
|
+
]
|
|
465
|
+
await writeFile(join(dir, 'sessions', '--demo-cwd--', `${SID}.jsonl`), lines.join('\n') + '\n')
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
beforeEach(async () => {
|
|
469
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-e7-'))
|
|
470
|
+
})
|
|
471
|
+
afterEach(async () => {
|
|
472
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('① allBranches:true → 旁支行标记 `[旁支 N entries]`(N=forkPoint 旁支子树 entry 数)+ 尾段同改前语义', async () => {
|
|
476
|
+
await writeForkSession()
|
|
477
|
+
const r = await handleSessionRead(
|
|
478
|
+
{ action: 'outline', session: SID, allBranches: true },
|
|
479
|
+
{ agentDir: dir },
|
|
480
|
+
)
|
|
481
|
+
const text = r.content[0].text
|
|
482
|
+
// 旁支标记带 entry 数;裸 `[旁支]` 旧形态零出现('[旁支]' 与 '[旁支 2 entries]' 互不为子串)
|
|
483
|
+
expect(text).toContain('T000 · fork point turn · → main line answer · [旁支 2 entries]')
|
|
484
|
+
expect(text).not.toContain('[旁支]')
|
|
485
|
+
// stats 尾段与改前语义一致(totalEntries = leaf 2 + 旁支 2)
|
|
486
|
+
expect(text).toContain('1 turns · 4 entries · ')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('② 两 action 一致性:export {format:"outline"} 物化全文 === outline content 文本(旁支行同形态)', async () => {
|
|
490
|
+
await writeForkSession()
|
|
491
|
+
const outline = await handleSessionRead(
|
|
492
|
+
{ action: 'outline', session: SID, allBranches: true },
|
|
493
|
+
{ agentDir: dir },
|
|
494
|
+
)
|
|
495
|
+
const exp = await handleSessionRead(
|
|
496
|
+
{ action: 'export', session: SID, format: 'outline', allBranches: true },
|
|
497
|
+
{ agentDir: dir },
|
|
498
|
+
)
|
|
499
|
+
const d = exp.details as { path: string }
|
|
500
|
+
const exported = await readFile(d.path, 'utf8')
|
|
501
|
+
// 干净 session(无坏行)下两 action 全文逐字节一致——同一 result.lines + 同一尾段拼装
|
|
502
|
+
expect(exported).toBe(outline.content[0].text)
|
|
503
|
+
expect(exported).toContain('[旁支 2 entries]')
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
435
507
|
describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
|
|
436
508
|
let dir: string
|
|
437
509
|
const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
|
|
@@ -445,21 +517,21 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
445
517
|
})
|
|
446
518
|
|
|
447
519
|
it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
|
|
448
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
|
|
520
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
|
|
449
521
|
const d = r.details as { hits: unknown[] }
|
|
450
522
|
// 字面量 '(a+)+' 不在内容里 → 0 命中(若按正则执行会命中 'aaa' 且可能指数回溯)
|
|
451
523
|
expect(d.hits).toHaveLength(0)
|
|
452
524
|
})
|
|
453
525
|
|
|
454
526
|
it('普通正则仍按正则匹配', async () => {
|
|
455
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, dir)
|
|
527
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, { agentDir: dir })
|
|
456
528
|
const d = r.details as { hits: unknown[] }
|
|
457
529
|
expect(d.hits.length).toBeGreaterThan(0)
|
|
458
530
|
})
|
|
459
531
|
|
|
460
532
|
it('范围量词 {m,n} 形态 → 降级字面子串((a{1,3})*、(a{0,2})*、(a{1,3}){2,},MF-1 回归)', async () => {
|
|
461
533
|
for (const pattern of ['(a{1,3})*', '(a{0,2})*', '(a{1,3}){2,}']) {
|
|
462
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
|
|
534
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
|
|
463
535
|
const d = r.details as { hits: unknown[] }
|
|
464
536
|
// 字面量不含这些 pattern → 0 命中;若按正则执行会指数回溯挂死
|
|
465
537
|
expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
|
|
@@ -468,7 +540,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
468
540
|
|
|
469
541
|
it('alternation/嵌套量词分支 (a|aa)+、(a*)* → 降级字面子串(MF-5 分支回归)', async () => {
|
|
470
542
|
for (const pattern of ['(a|aa)+', '(a*)*']) {
|
|
471
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
|
|
543
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
|
|
472
544
|
const d = r.details as { hits: unknown[] }
|
|
473
545
|
expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
|
|
474
546
|
}
|
|
@@ -477,7 +549,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
477
549
|
it('(a{1,3}) 单独使用不被降级(组后无尾随量词,仍按正则执行)', async () => {
|
|
478
550
|
const r = await handleSessionRead(
|
|
479
551
|
{ action: 'search', session: SID, pattern: '(a{1,3})' },
|
|
480
|
-
dir,
|
|
552
|
+
{ agentDir: dir },
|
|
481
553
|
)
|
|
482
554
|
const d = r.details as { hits: unknown[] }
|
|
483
555
|
// 按正则执行命中 'aaa' → >0 命中(若被降级为字面量则 0 命中)
|
|
@@ -485,12 +557,12 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
485
557
|
})
|
|
486
558
|
|
|
487
559
|
it('降级标注:header 含「已降级为字面子串匹配」(S-3)', async () => {
|
|
488
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
|
|
560
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
|
|
489
561
|
expect(r.content[0].text).toContain('已降级为字面子串匹配')
|
|
490
562
|
})
|
|
491
563
|
|
|
492
564
|
it('非法正则 pattern → 字面子串兜底不抛错(零命中,S-6)', async () => {
|
|
493
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, dir)
|
|
565
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, { agentDir: dir })
|
|
494
566
|
const d = r.details as { hits: unknown[] }
|
|
495
567
|
expect(d.hits).toHaveLength(0)
|
|
496
568
|
})
|
|
@@ -498,7 +570,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
498
570
|
it('scope 过滤:scope=assistant 零命中(fixture 仅 user 角色,S-6)', async () => {
|
|
499
571
|
const r = await handleSessionRead(
|
|
500
572
|
{ action: 'search', session: SID, pattern: 'aaa', scope: 'assistant' },
|
|
501
|
-
dir,
|
|
573
|
+
{ agentDir: dir },
|
|
502
574
|
)
|
|
503
575
|
const d = r.details as { hits: unknown[] }
|
|
504
576
|
expect(d.hits).toHaveLength(0)
|
|
@@ -525,7 +597,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
525
597
|
)
|
|
526
598
|
const r = await handleSessionRead(
|
|
527
599
|
{ action: 'search', session: sid2, pattern: 'aa', limit: 2 },
|
|
528
|
-
dir,
|
|
600
|
+
{ agentDir: dir },
|
|
529
601
|
)
|
|
530
602
|
const d = r.details as { hits: unknown[]; truncated: boolean }
|
|
531
603
|
expect(d.truncated).toBe(true)
|
|
@@ -536,7 +608,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
536
608
|
const ac = new AbortController()
|
|
537
609
|
ac.abort()
|
|
538
610
|
await expect(
|
|
539
|
-
handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, dir, ac.signal),
|
|
611
|
+
handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, { agentDir: dir }, ac.signal),
|
|
540
612
|
).rejects.toThrow(/中断/)
|
|
541
613
|
})
|
|
542
614
|
})
|
|
@@ -611,7 +683,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
611
683
|
},
|
|
612
684
|
}),
|
|
613
685
|
])
|
|
614
|
-
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
|
|
686
|
+
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
|
|
615
687
|
const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
|
|
616
688
|
const hashes = d.items.map((it) => it.hash)
|
|
617
689
|
expect(hashes).toContain('abc1234f')
|
|
@@ -669,7 +741,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
669
741
|
},
|
|
670
742
|
}),
|
|
671
743
|
])
|
|
672
|
-
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
|
|
744
|
+
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
|
|
673
745
|
const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
|
|
674
746
|
const dupes = d.items.filter((it) => it.hash === 'def5678a')
|
|
675
747
|
expect(dupes).toHaveLength(1)
|
|
@@ -811,7 +883,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
811
883
|
// export 的 details.path = session-view-<sessionId>.md,含 header 真实 id,不含文件名
|
|
812
884
|
const r = await handleSessionRead(
|
|
813
885
|
{ action: 'export', session: filePath, format: 'outline' },
|
|
814
|
-
dir,
|
|
886
|
+
{ agentDir: dir },
|
|
815
887
|
)
|
|
816
888
|
const d = r.details as { path: string }
|
|
817
889
|
expect(d.path).toContain(fileId)
|
|
@@ -826,10 +898,10 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
826
898
|
`not-exist-${Date.now()}.jsonl`,
|
|
827
899
|
)
|
|
828
900
|
await expect(
|
|
829
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
901
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
830
902
|
).rejects.toThrow(/读取失败.*文件不存在/)
|
|
831
903
|
await expect(
|
|
832
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
904
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
833
905
|
).rejects.toThrow('👉')
|
|
834
906
|
})
|
|
835
907
|
|
|
@@ -838,7 +910,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
838
910
|
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
839
911
|
await writeFile(filePath, 'not jsonl')
|
|
840
912
|
await expect(
|
|
841
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
913
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
842
914
|
).rejects.toThrow(/读取失败.*非 \.jsonl/)
|
|
843
915
|
})
|
|
844
916
|
|
|
@@ -847,7 +919,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
847
919
|
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
848
920
|
await writeFile(filePath, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
|
|
849
921
|
await expect(
|
|
850
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
922
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
851
923
|
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
852
924
|
})
|
|
853
925
|
|
|
@@ -856,7 +928,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
856
928
|
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
857
929
|
await writeFile(filePath, '')
|
|
858
930
|
await expect(
|
|
859
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
931
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
860
932
|
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
861
933
|
})
|
|
862
934
|
|
|
@@ -874,7 +946,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
874
946
|
const tildePath = '~/' + sessionFile.slice(home.length + 1)
|
|
875
947
|
const r = await handleSessionRead(
|
|
876
948
|
{ action: 'export', session: tildePath, format: 'outline' },
|
|
877
|
-
dir,
|
|
949
|
+
{ agentDir: dir },
|
|
878
950
|
)
|
|
879
951
|
expect((r.details as { path: string }).path).toContain(fileId)
|
|
880
952
|
} finally {
|
|
@@ -898,7 +970,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
898
970
|
await makeFixtureSubagent(dir, 'sa-aaa', { realSessionId: realId, firstUserText: 'do task' })
|
|
899
971
|
const r = await handleSessionRead(
|
|
900
972
|
{ action: 'export', session: 'sa-aaa', format: 'outline' },
|
|
901
|
-
dir,
|
|
973
|
+
{ agentDir: dir },
|
|
902
974
|
)
|
|
903
975
|
const d = r.details as { path: string }
|
|
904
976
|
expect(d.path).toContain(realId)
|
|
@@ -913,11 +985,11 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
913
985
|
sessionFileExists: false,
|
|
914
986
|
})
|
|
915
987
|
await expect(
|
|
916
|
-
handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir),
|
|
988
|
+
handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir }),
|
|
917
989
|
).rejects.toThrow('session 文件不存在')
|
|
918
990
|
// 错误含 manifest 全部元数据 + 👉
|
|
919
991
|
try {
|
|
920
|
-
await handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir)
|
|
992
|
+
await handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir })
|
|
921
993
|
} catch (e) {
|
|
922
994
|
const msg = (e as Error).message
|
|
923
995
|
expect(msg).toContain('sa-gc')
|
|
@@ -931,10 +1003,10 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
931
1003
|
|
|
932
1004
|
it('TC9: sa-id 0 命中(可能 running)→ ES2(含 family 指引 + 完整 id 提示 + 👉)', async () => {
|
|
933
1005
|
await expect(
|
|
934
|
-
handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir),
|
|
1006
|
+
handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
|
|
935
1007
|
).rejects.toThrow('无匹配 record')
|
|
936
1008
|
try {
|
|
937
|
-
await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
|
|
1009
|
+
await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
|
|
938
1010
|
} catch (e) {
|
|
939
1011
|
const msg = (e as Error).message
|
|
940
1012
|
expect(msg).toContain('可能尚未落盘')
|
|
@@ -949,7 +1021,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
949
1021
|
})
|
|
950
1022
|
// 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中 → ES2
|
|
951
1023
|
await expect(
|
|
952
|
-
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, dir),
|
|
1024
|
+
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
|
|
953
1025
|
).rejects.toThrow('无匹配 record')
|
|
954
1026
|
})
|
|
955
1027
|
|
|
@@ -978,12 +1050,12 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
978
1050
|
)
|
|
979
1051
|
}
|
|
980
1052
|
await expect(
|
|
981
|
-
handleSessionRead({ action: 'outline', session: 'sa-dup' }, dir),
|
|
1053
|
+
handleSessionRead({ action: 'outline', session: 'sa-dup' }, { agentDir: dir }),
|
|
982
1054
|
).rejects.toThrow(/匹配 2 个 record.*数据异常/)
|
|
983
1055
|
})
|
|
984
1056
|
|
|
985
1057
|
it('CQ3: sa-id 命中但 sessionFile header 读不出 → F6 风格(不降级 record.id 当 sessionId)', async () => {
|
|
986
|
-
// sessionFile 存在但首行非 session header →
|
|
1058
|
+
// sessionFile 存在但首行非 session header → readSessionHeaderIdSync 返 undefined
|
|
987
1059
|
const slug = '--demo-cwd--'
|
|
988
1060
|
const sessionFile = join(dir, 'subagents', slug, 'sessions', 'bad.jsonl')
|
|
989
1061
|
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
@@ -1001,7 +1073,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
1001
1073
|
)
|
|
1002
1074
|
// 抛 F6 风格(读取失败 + 首行非合法 session header),不降级返回 sa-bad 当 sessionId
|
|
1003
1075
|
await expect(
|
|
1004
|
-
handleSessionRead({ action: 'outline', session: 'sa-bad' }, dir),
|
|
1076
|
+
handleSessionRead({ action: 'outline', session: 'sa-bad' }, { agentDir: dir }),
|
|
1005
1077
|
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
1006
1078
|
})
|
|
1007
1079
|
})
|
|
@@ -1026,7 +1098,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1026
1098
|
// 无 source → 两者
|
|
1027
1099
|
const rBoth = await handleSessionRead(
|
|
1028
1100
|
{ action: 'find', query: sharedFragment },
|
|
1029
|
-
dir,
|
|
1101
|
+
{ agentDir: dir },
|
|
1030
1102
|
)
|
|
1031
1103
|
const dBoth = rBoth.details as {
|
|
1032
1104
|
matches: Array<{ source: string; sessionId: string }>
|
|
@@ -1037,7 +1109,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1037
1109
|
// source:subagent → 只 sub
|
|
1038
1110
|
const rSub = await handleSessionRead(
|
|
1039
1111
|
{ action: 'find', query: sharedFragment, source: 'subagent' },
|
|
1040
|
-
dir,
|
|
1112
|
+
{ agentDir: dir },
|
|
1041
1113
|
)
|
|
1042
1114
|
const dSub = rSub.details as {
|
|
1043
1115
|
matches: Array<{ source: string; sessionId: string }>
|
|
@@ -1049,7 +1121,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1049
1121
|
// source:main → 只 main
|
|
1050
1122
|
const rMain = await handleSessionRead(
|
|
1051
1123
|
{ action: 'find', query: sharedFragment, source: 'main' },
|
|
1052
|
-
dir,
|
|
1124
|
+
{ agentDir: dir },
|
|
1053
1125
|
)
|
|
1054
1126
|
const dMain = rMain.details as {
|
|
1055
1127
|
matches: Array<{ source: string; sessionId: string }>
|
|
@@ -1069,14 +1141,14 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1069
1141
|
// 无 source → main+sub 共享片段 → 2 匹配 → F2 消歧
|
|
1070
1142
|
const rMulti = await handleSessionRead(
|
|
1071
1143
|
{ action: 'outline', session: sharedFragment },
|
|
1072
|
-
dir,
|
|
1144
|
+
{ agentDir: dir },
|
|
1073
1145
|
)
|
|
1074
1146
|
expect((rMulti.details as { ambiguous: boolean }).ambiguous).toBe(true)
|
|
1075
1147
|
|
|
1076
1148
|
// source:main → 收窄到 main → 唯一匹配 → outline 成功,且是 main(export path 含 mainId)
|
|
1077
1149
|
const rExp = await handleSessionRead(
|
|
1078
1150
|
{ action: 'export', session: sharedFragment, source: 'main', format: 'outline' },
|
|
1079
|
-
dir,
|
|
1151
|
+
{ agentDir: dir },
|
|
1080
1152
|
)
|
|
1081
1153
|
expect((rExp.details as { path: string }).path).toContain(mainId)
|
|
1082
1154
|
})
|
|
@@ -1087,7 +1159,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1087
1159
|
const manifests = await listRecordManifests(REAL)
|
|
1088
1160
|
const alive = manifests.filter((m) => existsSync(m.sessionFile))
|
|
1089
1161
|
if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
|
|
1090
|
-
const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, REAL)
|
|
1162
|
+
const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, { agentDir: REAL })
|
|
1091
1163
|
const d = r.details as { turns: unknown[] }
|
|
1092
1164
|
expect(d.turns.length).toBeGreaterThan(0)
|
|
1093
1165
|
})
|
|
@@ -1098,7 +1170,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1098
1170
|
(m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
|
|
1099
1171
|
)
|
|
1100
1172
|
if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
|
|
1101
|
-
const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, REAL)
|
|
1173
|
+
const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, { agentDir: REAL })
|
|
1102
1174
|
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
1103
1175
|
})
|
|
1104
1176
|
|
|
@@ -1109,7 +1181,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1109
1181
|
// 绝对路径形态直接读(M0 入口不依赖 findSessions)
|
|
1110
1182
|
const r = await handleSessionRead(
|
|
1111
1183
|
{ action: 'outline', session: alive[0].sessionFile },
|
|
1112
|
-
REAL,
|
|
1184
|
+
{ agentDir: REAL },
|
|
1113
1185
|
)
|
|
1114
1186
|
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
1115
1187
|
})
|
|
@@ -1119,14 +1191,14 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1119
1191
|
const gc = manifests.filter((m) => !existsSync(m.sessionFile))
|
|
1120
1192
|
if (gc.length === 0) return // 本机无 GC 数据则跳过
|
|
1121
1193
|
await expect(
|
|
1122
|
-
handleSessionRead({ action: 'outline', session: gc[0].id }, REAL),
|
|
1194
|
+
handleSessionRead({ action: 'outline', session: gc[0].id }, { agentDir: REAL }),
|
|
1123
1195
|
).rejects.toThrow('session 文件不存在')
|
|
1124
1196
|
})
|
|
1125
1197
|
|
|
1126
1198
|
it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
|
|
1127
1199
|
const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
|
|
1128
1200
|
await expect(
|
|
1129
|
-
handleSessionRead({ action: 'outline', session: fakeId }, REAL),
|
|
1201
|
+
handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
|
|
1130
1202
|
).rejects.toThrow('无匹配 record')
|
|
1131
1203
|
})
|
|
1132
1204
|
}, 60000)
|
|
@@ -1258,7 +1330,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1258
1330
|
])
|
|
1259
1331
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-single-1', path: wfPath })
|
|
1260
1332
|
|
|
1261
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1333
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1262
1334
|
const d = r.details as {
|
|
1263
1335
|
runs: Array<{
|
|
1264
1336
|
runId: string
|
|
@@ -1296,7 +1368,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1296
1368
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-1', path: wf1 })
|
|
1297
1369
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-2', path: wf2 })
|
|
1298
1370
|
|
|
1299
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1371
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1300
1372
|
const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
|
|
1301
1373
|
expect(d.runs).toHaveLength(2)
|
|
1302
1374
|
expect(d.runIds).toHaveLength(2)
|
|
@@ -1321,7 +1393,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1321
1393
|
|
|
1322
1394
|
const r = await handleSessionRead(
|
|
1323
1395
|
{ action: 'workflow', session: WF_ROOT, runId: 'wf-filter-1' },
|
|
1324
|
-
dir,
|
|
1396
|
+
{ agentDir: dir },
|
|
1325
1397
|
)
|
|
1326
1398
|
const d = r.details as {
|
|
1327
1399
|
runs: Array<{ runId: string }>
|
|
@@ -1350,7 +1422,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1350
1422
|
|
|
1351
1423
|
const r = await handleSessionRead(
|
|
1352
1424
|
{ action: 'workflow', session: WF_ROOT, runId: 'wf-nonexist' },
|
|
1353
|
-
dir,
|
|
1425
|
+
{ agentDir: dir },
|
|
1354
1426
|
)
|
|
1355
1427
|
const d = r.details as { runs: unknown[]; runIds: string[]; requestedRunId?: string }
|
|
1356
1428
|
expect(d.runs).toEqual([])
|
|
@@ -1369,7 +1441,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1369
1441
|
await wfMainSession(dir, slug, WF_ROOT)
|
|
1370
1442
|
// 无 wf-link(session 存在但未发起 workflow)
|
|
1371
1443
|
|
|
1372
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1444
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1373
1445
|
const d = r.details as { runs: unknown[]; runIds: unknown[]; sessionId?: string }
|
|
1374
1446
|
expect(d.runs).toEqual([])
|
|
1375
1447
|
expect(d.runIds).toEqual([])
|
|
@@ -1391,7 +1463,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1391
1463
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-1', path: ghostPath })
|
|
1392
1464
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-2', path: wf2 })
|
|
1393
1465
|
|
|
1394
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1466
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1395
1467
|
const d = r.details as {
|
|
1396
1468
|
runs: Array<{ runId: string }>
|
|
1397
1469
|
runIds: string[]
|
|
@@ -1421,7 +1493,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1421
1493
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-jump-1', path: wfPath })
|
|
1422
1494
|
|
|
1423
1495
|
// 第一次:workflow 概览,拿 call sessionId
|
|
1424
|
-
const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1496
|
+
const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1425
1497
|
const dWf = rWf.details as {
|
|
1426
1498
|
runs: Array<{ steps: Array<{ sessionId: string; sessionFile: string }> }>
|
|
1427
1499
|
}
|
|
@@ -1432,7 +1504,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1432
1504
|
// 第二次:用 call sessionId 调 outline(m0 resolveSessionId 三形态复用)
|
|
1433
1505
|
const rOutline = await handleSessionRead(
|
|
1434
1506
|
{ action: 'outline', session: callSessionId },
|
|
1435
|
-
dir,
|
|
1507
|
+
{ agentDir: dir },
|
|
1436
1508
|
)
|
|
1437
1509
|
const dOutline = rOutline.details as { turns: unknown[] }
|
|
1438
1510
|
expect(dOutline.turns.length).toBeGreaterThan(0)
|
|
@@ -1469,7 +1541,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1469
1541
|
{ flag: 'a' },
|
|
1470
1542
|
)
|
|
1471
1543
|
|
|
1472
|
-
const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, dir)
|
|
1544
|
+
const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, { agentDir: dir })
|
|
1473
1545
|
const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
|
|
1474
1546
|
expect(d.runs).toHaveLength(1)
|
|
1475
1547
|
expect(d.runs[0].runId).toBe('wf-sub-1')
|
|
@@ -1490,7 +1562,7 @@ describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
|
|
|
1490
1562
|
it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
|
|
1491
1563
|
const r = await handleSessionRead(
|
|
1492
1564
|
{ action: 'workflow', session: REAL_WF_SESSION },
|
|
1493
|
-
REAL,
|
|
1565
|
+
{ agentDir: REAL },
|
|
1494
1566
|
)
|
|
1495
1567
|
const d = r.details as {
|
|
1496
1568
|
runs: Array<{ runId: string; status: string; steps: unknown[] }>
|
|
@@ -1529,7 +1601,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1529
1601
|
})
|
|
1530
1602
|
|
|
1531
1603
|
it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
|
|
1532
|
-
const r = await handleSessionRead({ action: 'family', session: MAIN }, dir)
|
|
1604
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1533
1605
|
// details 是 Family 对象(root/subagents/workflows),非 { tree }
|
|
1534
1606
|
const d = r.details as {
|
|
1535
1607
|
root: { sessionId: string }
|
|
@@ -1547,7 +1619,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1547
1619
|
it('TC-m3b-dofamily-recursive-true:recursive=true → ExecutionTree(details.tree)', async () => {
|
|
1548
1620
|
const r = await handleSessionRead(
|
|
1549
1621
|
{ action: 'family', session: MAIN, recursive: true },
|
|
1550
|
-
dir,
|
|
1622
|
+
{ agentDir: dir },
|
|
1551
1623
|
)
|
|
1552
1624
|
const d = r.details as {
|
|
1553
1625
|
tree: {
|
|
@@ -1573,6 +1645,142 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1573
1645
|
})
|
|
1574
1646
|
})
|
|
1575
1647
|
|
|
1648
|
+
// ===========================================================================
|
|
1649
|
+
// ext-simplify-04 U4/E2(D2②):formatFamilyText subagents 行富字段展示
|
|
1650
|
+
// status 终态短标签 + agent 名 + task 截 60 摘要;孤儿只标 [已清理];P4 输出量级守卫。
|
|
1651
|
+
// ===========================================================================
|
|
1652
|
+
|
|
1653
|
+
describe('formatFamilyText subagents 行富字段(ext-simplify-04 U4 / E2)', () => {
|
|
1654
|
+
const MAIN = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f1'
|
|
1655
|
+
const SUB = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f2'
|
|
1656
|
+
let dir: string
|
|
1657
|
+
|
|
1658
|
+
beforeEach(async () => {
|
|
1659
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-family-rich-'))
|
|
1660
|
+
await makeFixtureSession(dir, MAIN, 'main session')
|
|
1661
|
+
})
|
|
1662
|
+
afterEach(async () => {
|
|
1663
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1664
|
+
})
|
|
1665
|
+
|
|
1666
|
+
/** alive subagent:session 文件(header + 可选 identity 尾行)+ 可选 manifest 富字段 */
|
|
1667
|
+
async function writeSubagent(
|
|
1668
|
+
realId: string,
|
|
1669
|
+
opts: {
|
|
1670
|
+
manifest?: { agentName?: string; task?: string; slug?: string; model?: string; status?: string }
|
|
1671
|
+
identityTask?: string
|
|
1672
|
+
identityAgent?: string
|
|
1673
|
+
} = {},
|
|
1674
|
+
): Promise<void> {
|
|
1675
|
+
const sessionDir = join(dir, 'subagents', '--demo-cwd--', 'sessions')
|
|
1676
|
+
await mkdir(sessionDir, { recursive: true })
|
|
1677
|
+
const subPath = join(sessionDir, `${realId}.jsonl`)
|
|
1678
|
+
const lines = [JSON.stringify({ type: 'session', id: realId, cwd: '/demo' })]
|
|
1679
|
+
if (opts.identityTask !== undefined || opts.identityAgent !== undefined) {
|
|
1680
|
+
lines.push(
|
|
1681
|
+
JSON.stringify({
|
|
1682
|
+
type: 'custom',
|
|
1683
|
+
customType: 'subagent-identity',
|
|
1684
|
+
data: {
|
|
1685
|
+
id: `sa-${realId.slice(0, 8)}`,
|
|
1686
|
+
rootSessionId: MAIN,
|
|
1687
|
+
slug: 'identity-slug',
|
|
1688
|
+
agent: opts.identityAgent ?? 'explorer',
|
|
1689
|
+
task: opts.identityTask ?? 't',
|
|
1690
|
+
},
|
|
1691
|
+
}),
|
|
1692
|
+
)
|
|
1693
|
+
}
|
|
1694
|
+
await writeFile(subPath, lines.join('\n') + '\n')
|
|
1695
|
+
if (opts.manifest) {
|
|
1696
|
+
const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
|
|
1697
|
+
await mkdir(recordsDir, { recursive: true })
|
|
1698
|
+
await writeFile(
|
|
1699
|
+
join(recordsDir, `sa-${realId}.json`),
|
|
1700
|
+
JSON.stringify({
|
|
1701
|
+
id: `sa-${realId}`,
|
|
1702
|
+
rootSessionId: MAIN,
|
|
1703
|
+
sessionFile: subPath,
|
|
1704
|
+
...opts.manifest,
|
|
1705
|
+
}),
|
|
1706
|
+
)
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/** 孤儿 manifest:sessionFile 指向不存在路径(模拟 .jsonl 被 GC) */
|
|
1711
|
+
async function writeOrphanManifest(
|
|
1712
|
+
saId: string,
|
|
1713
|
+
fields: { agentName?: string; task?: string; slug?: string; status?: string } = {},
|
|
1714
|
+
): Promise<void> {
|
|
1715
|
+
const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
|
|
1716
|
+
await mkdir(recordsDir, { recursive: true })
|
|
1717
|
+
await writeFile(
|
|
1718
|
+
join(recordsDir, `${saId}.json`),
|
|
1719
|
+
JSON.stringify({ id: saId, rootSessionId: MAIN, sessionFile: '/nonexistent/gc.jsonl', ...fields }),
|
|
1720
|
+
)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
it('manifest 主路径:subagents 行含 [status] agent 名 · task 摘要;root 段不变', async () => {
|
|
1724
|
+
await writeSubagent(SUB, {
|
|
1725
|
+
manifest: { agentName: 'coder', task: '修复登录表单校验并补测试', slug: 'fix-login', status: 'completed' },
|
|
1726
|
+
})
|
|
1727
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1728
|
+
const text = r.content[0].text
|
|
1729
|
+
// 富字段行形态(设计 §3.1):slug 后接 [status] agent · task 摘要
|
|
1730
|
+
expect(text).toContain('slug=fix-login [completed] coder · 修复登录表单校验并补测试')
|
|
1731
|
+
// root/parents/forks/workflows 段不变(root 行含完整 id + 日期括号)
|
|
1732
|
+
expect(text).toContain(`root: ${MAIN} (`)
|
|
1733
|
+
})
|
|
1734
|
+
|
|
1735
|
+
it('P4 输出量级:task 截 60 字符(超出加省略号),换行压平不破坏行结构', async () => {
|
|
1736
|
+
const SUB2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f3'
|
|
1737
|
+
await writeSubagent(SUB, {
|
|
1738
|
+
manifest: { agentName: 'coder', task: 'A'.repeat(100), slug: 'long-task', status: 'running' },
|
|
1739
|
+
})
|
|
1740
|
+
await writeSubagent(SUB2, {
|
|
1741
|
+
manifest: { agentName: 'worker', task: 'line1\nline2\ttabbed', slug: 'nl-task', status: 'failed' },
|
|
1742
|
+
})
|
|
1743
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1744
|
+
const text = r.content[0].text
|
|
1745
|
+
// 截断:恰 60 字符 + 省略号,原文 61+ 连续段不出现
|
|
1746
|
+
expect(text).toContain(`${'A'.repeat(60)}…`)
|
|
1747
|
+
expect(text).not.toContain('A'.repeat(61))
|
|
1748
|
+
// 换行/制表压平为单空格
|
|
1749
|
+
expect(text).toContain('slug=nl-task [failed] worker · line1 line2 tabbed')
|
|
1750
|
+
// 行结构:subagents 段 = 头行 + 每 subagent 恰一行(换行未折叠出额外行)
|
|
1751
|
+
const lines = text.split('\n')
|
|
1752
|
+
const headIdx = lines.indexOf('subagents:')
|
|
1753
|
+
const subLines = lines.slice(headIdx + 1)
|
|
1754
|
+
expect(subLines).toHaveLength(2)
|
|
1755
|
+
expect(subLines.every((l) => l.startsWith(' ') && l.trim().length > 0)).toBe(true)
|
|
1756
|
+
})
|
|
1757
|
+
|
|
1758
|
+
it('孤儿(cleanedUp):仍标 [已清理],不展开 status/agent/task 摘要', async () => {
|
|
1759
|
+
await writeOrphanManifest('sa-ghost-id', {
|
|
1760
|
+
agentName: 'worker',
|
|
1761
|
+
task: 'ghost task',
|
|
1762
|
+
slug: 'ghost-slug',
|
|
1763
|
+
status: 'completed',
|
|
1764
|
+
})
|
|
1765
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1766
|
+
const text = r.content[0].text
|
|
1767
|
+
expect(text).toContain('slug=ghost-slug [已清理]')
|
|
1768
|
+
// 已清理即终局:manifest 富字段不进文本(无深读入口,摘要无消费价值)
|
|
1769
|
+
expect(text).not.toContain('ghost task')
|
|
1770
|
+
expect(text).not.toContain('[completed]')
|
|
1771
|
+
})
|
|
1772
|
+
|
|
1773
|
+
it('P-fallback(无 manifest):identity 回退 task/agent 仍展示,status 无则无标签', async () => {
|
|
1774
|
+
await writeSubagent(SUB, { identityTask: 'fix bug', identityAgent: 'worker' })
|
|
1775
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1776
|
+
const text = r.content[0].text
|
|
1777
|
+
// P-fallback:task/agent 从 identity 回退(slug 取 identity.slug),status 不可回退 → 无 [x] 标签
|
|
1778
|
+
expect(text).toContain('slug=identity-slug worker · fix bug')
|
|
1779
|
+
expect(text).not.toContain('[completed]')
|
|
1780
|
+
expect(text).not.toContain('[running]')
|
|
1781
|
+
})
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1576
1784
|
// ===========================================================================
|
|
1577
1785
|
// doctor action(u8:design 2026-09-10 §6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)
|
|
1578
1786
|
// 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
|
|
@@ -1581,7 +1789,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1581
1789
|
// doctor details 契约直用 doctor.ts 导出的 DoctorDetails(不再自建局部镜像——生产侧
|
|
1582
1790
|
// 形状漂移由编译期捕捉,S-R2 同步)
|
|
1583
1791
|
|
|
1584
|
-
describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob
|
|
1792
|
+
describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob)', () => {
|
|
1585
1793
|
let tmp: string
|
|
1586
1794
|
|
|
1587
1795
|
const SLUG = '--Users-foo--'
|
|
@@ -1753,59 +1961,31 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1753
1961
|
expect(fullSub?.exists).toBe(true)
|
|
1754
1962
|
})
|
|
1755
1963
|
|
|
1756
|
-
it('
|
|
1964
|
+
it('无缓存形态(ext-simplify-04 U3):重复调用输出一致(每次实扫),新落盘文件立即可见', async () => {
|
|
1965
|
+
// 原三个用例(缓存命中不重扫 / TTL 到期失效 / find 不读 doctor 缓存 PS-14)随
|
|
1966
|
+
// doctor 缓存机删除而合并为本用例——无缓存层后「find/doctor 读到旧计数」的形态
|
|
1967
|
+
// 在结构上不存在,守卫点收敛为:输出无「缓存命中」行 + 每次调用实扫(新增文件
|
|
1968
|
+
// 下一次调用即见,反而更实时)
|
|
1757
1969
|
const agentDir = join(tmp, 'agent')
|
|
1758
1970
|
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1759
1971
|
const signals: SessionReadSignals = { agentDir }
|
|
1760
1972
|
|
|
1761
1973
|
const first = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
|
|
1762
1974
|
const firstDef = first.roots.find((x) => x.kind === 'default')!
|
|
1763
|
-
expect(firstDef.cached).toBeUndefined() // 首扫
|
|
1764
1975
|
expect(firstDef.fileCount).toBe(1)
|
|
1765
1976
|
|
|
1766
|
-
const second =
|
|
1767
|
-
const secondDef = second.roots.find((x) => x.kind === 'default')!
|
|
1768
|
-
expect(secondDef.
|
|
1769
|
-
expect(
|
|
1977
|
+
const second = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1978
|
+
const secondDef = (second.details as DoctorDetails).roots.find((x) => x.kind === 'default')!
|
|
1979
|
+
expect(secondDef.fileCount).toBe(1) // 两次输出一致(同目录无变化)
|
|
1980
|
+
expect(second.content[0].text).not.toContain('缓存命中')
|
|
1770
1981
|
|
|
1771
|
-
//
|
|
1772
|
-
await writeJsonl(join(agentDir, 'sessions', 'b.jsonl'), 'bbbbbbbb')
|
|
1982
|
+
// 新 session 落盘 → 下一次 doctor / find 均立即可见(无缓存层,每次实扫)
|
|
1983
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
|
|
1773
1984
|
const third = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
expect(thirdDef.fileCount).toBe(2)
|
|
1777
|
-
})
|
|
1778
|
-
|
|
1779
|
-
it('缓存 TTL 到期失效重扫(秒级 TTL,fake timers 推进时钟)', async () => {
|
|
1780
|
-
vi.useFakeTimers()
|
|
1781
|
-
try {
|
|
1782
|
-
const agentDir = join(tmp, 'agent-ttl')
|
|
1783
|
-
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1784
|
-
const signals: SessionReadSignals = { agentDir }
|
|
1785
|
-
const first = (await handleSessionRead({ action: 'doctor' }, signals))
|
|
1786
|
-
.details as DoctorDetails
|
|
1787
|
-
expect(first.roots.find((x) => x.kind === 'default')!.cached).toBeUndefined()
|
|
1788
|
-
await vi.advanceTimersByTimeAsync(DOCTOR_CACHE_TTL_MS + 1)
|
|
1789
|
-
const second = (await handleSessionRead({ action: 'doctor' }, signals))
|
|
1790
|
-
.details as DoctorDetails
|
|
1791
|
-
// TTL 过期 → 即使 mtime 未变也重扫
|
|
1792
|
-
expect(second.roots.find((x) => x.kind === 'default')!.cached).toBeUndefined()
|
|
1793
|
-
} finally {
|
|
1794
|
-
vi.useRealTimers()
|
|
1795
|
-
}
|
|
1796
|
-
})
|
|
1797
|
-
|
|
1798
|
-
it('find 不读 doctor 缓存(§7B 要点 8 PS-14):doctor 缓存 0 文件后新建 session,find 立即可见', async () => {
|
|
1799
|
-
const agentDir = join(tmp, 'agent')
|
|
1800
|
-
// doctor 首跑时 main 根为空 → 缓存 fileCount=0(PS-14 形态:首条 assistant 前 jsonl 不落盘)
|
|
1801
|
-
await mkdir(join(agentDir, 'sessions'), { recursive: true })
|
|
1802
|
-
await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1803
|
-
// 之后新 session 落盘
|
|
1804
|
-
await writeJsonl(join(agentDir, 'sessions', SLUG, 'found-later.jsonl'), 'id-find-later-xyz')
|
|
1805
|
-
// find 实扫立即可见——若 find 读 doctor 缓存(0 候选)此断言必红
|
|
1806
|
-
const r = await handleSessionRead({ action: 'find', query: 'id-find-later-xyz' }, agentDir)
|
|
1985
|
+
expect(third.roots.find((x) => x.kind === 'default')!.fileCount).toBe(2)
|
|
1986
|
+
const r = await handleSessionRead({ action: 'find', query: 'bbbbbbbb' }, { agentDir })
|
|
1807
1987
|
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
1808
|
-
expect(d.matches.some((m) => m.sessionId.includes('
|
|
1988
|
+
expect(d.matches.some((m) => m.sessionId.includes('bbbbbbbb'))).toBe(true)
|
|
1809
1989
|
})
|
|
1810
1990
|
|
|
1811
1991
|
it('env/bundleUrl 信号缺失降级:仍出环境判定行(standalone-pi · 未知)+ 完整根表,不抛错', async () => {
|
|
@@ -1871,7 +2051,7 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
|
|
|
1871
2051
|
|
|
1872
2052
|
/** find action 便捷调用(agentDir = tmp/agent)。 */
|
|
1873
2053
|
function find(query: string, extra?: Partial<SessionReadParams>): Promise<ToolResultLike> {
|
|
1874
|
-
return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
|
|
2054
|
+
return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
|
|
1875
2055
|
}
|
|
1876
2056
|
|
|
1877
2057
|
interface ToolResultLike {
|
|
@@ -1985,9 +2165,10 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
|
|
|
1985
2165
|
expect((hexJunk.details as { matches: unknown[] }).matches).toHaveLength(0)
|
|
1986
2166
|
})
|
|
1987
2167
|
|
|
1988
|
-
it('⑥ 自检行计数 =
|
|
2168
|
+
it('⑥ 自检行计数 = 本次实扫结果 + 去重根注记', async () => {
|
|
1989
2169
|
const agentDir = join(tmp, 'agent')
|
|
1990
|
-
//
|
|
2170
|
+
// 根扫描无缓存(doctor 缓存机已删,ext-simplify-04 U3)——doctor 首跑后落盘的
|
|
2171
|
+
// 文件,find 自检行照实扫计数(原 PS-14「doctor 缓存 0 文件」形态结构性不存在)
|
|
1991
2172
|
await mkdir(join(agentDir, 'sessions'), { recursive: true })
|
|
1992
2173
|
await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1993
2174
|
// 之后 session 落盘(main 2 + subagent 1)
|
|
@@ -2000,7 +2181,6 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
|
|
|
2000
2181
|
{ agentDir },
|
|
2001
2182
|
)
|
|
2002
2183
|
const text = r.content[0].text
|
|
2003
|
-
// 若 F1 读 doctor 缓存(0 文件),以下计数断言必红
|
|
2004
2184
|
expect(text).toContain(`${join(agentDir, 'sessions')}:2 文件`)
|
|
2005
2185
|
expect(text).toContain(`${join(agentDir, 'subagents')}:1 文件`)
|
|
2006
2186
|
expect(text).toContain('候选集非空(共 3 文件)')
|
|
@@ -2072,7 +2252,7 @@ describe('片段解析接通 [live] 根(fixture,同一 roots 契约)', ()
|
|
|
2072
2252
|
// 负向对照:不传 liveSessionDir(三根降级)→ 同一片段 F1 零匹配
|
|
2073
2253
|
//([default]/[legacy]/[subagent] 均无此文件——证明命中确实来自 [live] 根)
|
|
2074
2254
|
await expect(
|
|
2075
|
-
handleSessionRead({ action: 'outline', session: FRAGMENT }, agentDir),
|
|
2255
|
+
handleSessionRead({ action: 'outline', session: FRAGMENT }, { agentDir }),
|
|
2076
2256
|
).rejects.toThrow(/无匹配 session/)
|
|
2077
2257
|
})
|
|
2078
2258
|
|
|
@@ -2176,7 +2356,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2176
2356
|
query: string,
|
|
2177
2357
|
extra?: Partial<SessionReadParams>,
|
|
2178
2358
|
): ReturnType<typeof handleSessionRead> {
|
|
2179
|
-
return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
|
|
2359
|
+
return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
|
|
2180
2360
|
}
|
|
2181
2361
|
|
|
2182
2362
|
interface FindDetails {
|
|
@@ -2304,7 +2484,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2304
2484
|
|
|
2305
2485
|
const res = await handleSessionRead(
|
|
2306
2486
|
{ action: 'result', session: 'sa-u10-r1,sa-u10-r2' },
|
|
2307
|
-
join(tmp, 'agent'),
|
|
2487
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2308
2488
|
)
|
|
2309
2489
|
const text = res.content[0].text
|
|
2310
2490
|
expect(text).toContain(`(session ${R1.slice(0, 8)}…)`)
|
|
@@ -2331,7 +2511,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2331
2511
|
|
|
2332
2512
|
// 解析路径(resolveByFragment → findSessions limit:10 无 source)保持 mtime 排序 +
|
|
2333
2513
|
// limit 截断原语义:5 命中全进 F2 消歧候选,无 main 置顶/配额分组介入(§6.7 末条)
|
|
2334
|
-
const r = await handleSessionRead({ action: 'outline', session: PREFIX }, join(tmp, 'agent'))
|
|
2514
|
+
const r = await handleSessionRead({ action: 'outline', session: PREFIX }, { agentDir: join(tmp, 'agent') })
|
|
2335
2515
|
const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
|
|
2336
2516
|
expect(d.ambiguous).toBe(true)
|
|
2337
2517
|
expect(d.candidates).toHaveLength(5)
|
|
@@ -2355,9 +2535,107 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2355
2535
|
})
|
|
2356
2536
|
})
|
|
2357
2537
|
|
|
2538
|
+
// ============================================================
|
|
2539
|
+
// E1:find 单次根解析(ext-simplify-04 U2,探针 P1)。spy 打在 roots 模块命名空间
|
|
2540
|
+
// 上跨模块拦截(find.ts / findNoMatch 渲染链的消费点全走同一入口),断言 doFind
|
|
2541
|
+
// 全路径(分组 / 显式 source / 零匹配)resolveSessionRoots 恰调用 1 次——改前分组
|
|
2542
|
+
// 2 次、零匹配 3 次(impl-plan D-15④ 登记的分组双倍实扫债),预解析根复用后恒 1 次。
|
|
2543
|
+
// ============================================================
|
|
2544
|
+
describe('E1 find 单次根解析(探针 P1:resolveSessionRoots 恰 1 次)', () => {
|
|
2545
|
+
let tmp: string
|
|
2546
|
+
const SLUG = '--e1-cwd--'
|
|
2547
|
+
const PREFIX = '019e7e10'
|
|
2548
|
+
const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
|
|
2549
|
+
const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
|
|
2550
|
+
|
|
2551
|
+
/** 写 session 文件(subagent:true 写入 subagent 根),形态同 u10 段 writeSession。 */
|
|
2552
|
+
async function writeSession(id: string, opts?: { subagent?: boolean }): Promise<void> {
|
|
2553
|
+
const dir = opts?.subagent
|
|
2554
|
+
? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
|
|
2555
|
+
: join(tmp, 'agent', 'sessions', SLUG)
|
|
2556
|
+
await mkdir(dir, { recursive: true })
|
|
2557
|
+
await writeFile(
|
|
2558
|
+
join(dir, `${id}.jsonl`),
|
|
2559
|
+
JSON.stringify({ type: 'session', id, cwd: '/e1' }) + '\n',
|
|
2560
|
+
)
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
function find(
|
|
2564
|
+
query: string,
|
|
2565
|
+
extra?: Partial<SessionReadParams>,
|
|
2566
|
+
): ReturnType<typeof handleSessionRead> {
|
|
2567
|
+
return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/** spy 计数器 + 断言 helper:包装一段 find 调用,返回 { 结果, 调用次数 }。 */
|
|
2571
|
+
async function findCounting(
|
|
2572
|
+
query: string,
|
|
2573
|
+
extra?: Partial<SessionReadParams>,
|
|
2574
|
+
): Promise<{ result: Awaited<ReturnType<typeof handleSessionRead>>; resolveCalls: number }> {
|
|
2575
|
+
const rootsModule = await import('../discovery/roots.js')
|
|
2576
|
+
const spy = vi.spyOn(rootsModule, 'resolveSessionRoots')
|
|
2577
|
+
try {
|
|
2578
|
+
const result = await find(query, extra)
|
|
2579
|
+
return { result, resolveCalls: spy.mock.calls.length }
|
|
2580
|
+
} finally {
|
|
2581
|
+
spy.mockRestore()
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
beforeEach(async () => {
|
|
2586
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-e1-'))
|
|
2587
|
+
})
|
|
2588
|
+
afterEach(async () => {
|
|
2589
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
2590
|
+
})
|
|
2591
|
+
|
|
2592
|
+
it('① 分组路径(main + subagent 两段查询,有命中):恰 1 次', async () => {
|
|
2593
|
+
await writeSession(mainId(1))
|
|
2594
|
+
await writeSession(subId(1), { subagent: true })
|
|
2595
|
+
const { result, resolveCalls } = await findCounting(PREFIX)
|
|
2596
|
+
const d = result.details as { matches: Array<{ source: string }>; truncated: boolean }
|
|
2597
|
+
// 前置:两段确实各自命中(分组语义不变),再断言扫描收敛
|
|
2598
|
+
expect(d.matches).toHaveLength(2)
|
|
2599
|
+
expect(new Set(d.matches.map((m) => m.source))).toEqual(new Set(['main', 'subagent']))
|
|
2600
|
+
expect(d.truncated).toBe(false)
|
|
2601
|
+
expect(resolveCalls).toBe(1)
|
|
2602
|
+
})
|
|
2603
|
+
|
|
2604
|
+
it('② 显式 source 路径(单组查询,有命中):恰 1 次', async () => {
|
|
2605
|
+
await writeSession(mainId(1))
|
|
2606
|
+
await writeSession(subId(1), { subagent: true })
|
|
2607
|
+
const { result, resolveCalls } = await findCounting(PREFIX, { source: 'subagent' })
|
|
2608
|
+
const d = result.details as { matches: Array<{ source: string }> }
|
|
2609
|
+
expect(d.matches).toHaveLength(1)
|
|
2610
|
+
expect(d.matches[0].source).toBe('subagent')
|
|
2611
|
+
expect(resolveCalls).toBe(1)
|
|
2612
|
+
})
|
|
2613
|
+
|
|
2614
|
+
it('③ 分组零匹配 → F1 自检行:恰 1 次(改前 3 次)且根表仍进渲染', async () => {
|
|
2615
|
+
await writeSession(mainId(1))
|
|
2616
|
+
const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x')
|
|
2617
|
+
const d = result.details as { matches: unknown[]; truncated: boolean }
|
|
2618
|
+
expect(d.matches).toEqual([])
|
|
2619
|
+
expect(d.truncated).toBe(false)
|
|
2620
|
+
// F1 自检行消费预解析根表(非空根计数可见 = roots 确实传入而非空列表)
|
|
2621
|
+
expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
|
|
2622
|
+
expect(result.content[0].text).toContain('候选集非空')
|
|
2623
|
+
expect(resolveCalls).toBe(1)
|
|
2624
|
+
})
|
|
2625
|
+
|
|
2626
|
+
it('④ 显式 source 零匹配 → F1 自检行:恰 1 次(改前 2 次)', async () => {
|
|
2627
|
+
await writeSession(subId(1), { subagent: true })
|
|
2628
|
+
const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x', { source: 'main' })
|
|
2629
|
+
expect(result.content[0].text).toContain('无匹配 session')
|
|
2630
|
+
expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
|
|
2631
|
+
expect(resolveCalls).toBe(1)
|
|
2632
|
+
})
|
|
2633
|
+
})
|
|
2634
|
+
|
|
2358
2635
|
// ============================================================
|
|
2359
2636
|
// u11:metadataProvider 注入 + TTL 缓存(design 2026-09-10 §6.6)
|
|
2360
|
-
// handler 级集成:注入包装(
|
|
2637
|
+
// handler 级集成:注入包装(metadata 缓存独立实例——根扫描无缓存,doctor 缓存机
|
|
2638
|
+
// 已删除,ext-simplify-04 U3,不存在串扰面)
|
|
2361
2639
|
// + 标题渲染(§5.1 候选行标题优先)+ provider 抛错降级不外抛。
|
|
2362
2640
|
// 策略①惰性/②窄化/合并语义在 find.test.ts u11 段覆盖。
|
|
2363
2641
|
// ============================================================
|
|
@@ -2567,7 +2845,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2567
2845
|
const ids = Array.from({ length: MULTI_SEARCH_MAX_SESSIONS + 1 }, (_, i) => mainId(i + 10))
|
|
2568
2846
|
const r = await handleSessionRead(
|
|
2569
2847
|
{ action: 'search', session: ids.join(','), pattern: 'adj_factor' },
|
|
2570
|
-
join(tmp, 'agent'),
|
|
2848
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2571
2849
|
)
|
|
2572
2850
|
const text = r.content[0].text
|
|
2573
2851
|
expect(text).toContain('跨会话检索已拒绝')
|
|
@@ -2697,7 +2975,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2697
2975
|
await writeSession(A, [{ role: 'user', text: '普通内容' }])
|
|
2698
2976
|
const r = await handleSessionRead(
|
|
2699
2977
|
{ action: 'search', session: `${A},${GHOST}`, pattern: 'zzznothing' },
|
|
2700
|
-
join(tmp, 'agent'),
|
|
2978
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2701
2979
|
)
|
|
2702
2980
|
const text = r.content[0].text
|
|
2703
2981
|
expect(text).toContain('0/1 session(s) hit for /zzznothing/')
|
|
@@ -2721,7 +2999,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2721
2999
|
// 单 id:原 details.hits 结构与 header 形态不变
|
|
2722
3000
|
const r = await handleSessionRead(
|
|
2723
3001
|
{ action: 'search', session: A, pattern: 'plugin' },
|
|
2724
|
-
join(tmp, 'agent'),
|
|
3002
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2725
3003
|
)
|
|
2726
3004
|
expect(r.content[0].text).toContain('1 hit(s) for /plugin/')
|
|
2727
3005
|
const d = r.details as { hits: unknown[]; truncated: boolean; scanned?: unknown }
|
|
@@ -2731,7 +3009,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2731
3009
|
// pattern 含逗号不触发跨会话(分流只看 session)
|
|
2732
3010
|
const r2 = await handleSessionRead(
|
|
2733
3011
|
{ action: 'search', session: A, pattern: 'aaa,bbb' },
|
|
2734
|
-
join(tmp, 'agent'),
|
|
3012
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2735
3013
|
)
|
|
2736
3014
|
expect((r2.details as { hits: unknown[] }).hits).toHaveLength(0)
|
|
2737
3015
|
expect((r2.details as { scanned?: unknown }).scanned).toBeUndefined()
|