@zhushanwen/pi-session-reader 0.5.1 → 0.6.1
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 +6 -3
- package/src/__tests__/cross-package-subagent-core.test.ts +42 -49
- package/src/__tests__/env.test.ts +54 -54
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +6 -6
- package/src/__tests__/index.test.ts +1 -1
- package/src/__tests__/real-data.ts +23 -10
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +47 -81
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +5 -7
- package/src/__tests__/tool-handler.test.ts +481 -216
- 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 +41 -14
- package/src/core/toolcall.ts +1 -2
- package/src/core/units.ts +14 -0
- package/src/core/workflow.ts +7 -20
- package/src/discovery/env.ts +41 -41
- package/src/discovery/find.ts +43 -135
- package/src/discovery/roots.ts +18 -89
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +12 -143
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/index.ts +2 -2
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +29 -34
- package/src/search-across.ts +6 -8
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
- package/vitest.config.ts +2 -4
|
@@ -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,
|
|
@@ -29,11 +30,20 @@ import {
|
|
|
29
30
|
hasRealSession,
|
|
30
31
|
} from './real-data.js'
|
|
31
32
|
|
|
33
|
+
// fs-guard:TC6 的「~ 前缀展开」语义需要 homedir,但真实 homedir 根在白名单外(禁止向用户
|
|
34
|
+
// 主目录散落文件)。vi.mock 把 homedir 指到用例内 mkdtemp 的 tmp;fakeHome 未注入时回落真实值,
|
|
35
|
+
// real-data.ts 模块初始化时推导 REAL_AGENT_DIR 不受影响(模块初始化先于任何用例执行)。
|
|
36
|
+
const homeState = vi.hoisted(() => ({ fakeHome: undefined as string | undefined }))
|
|
37
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
38
|
+
const actual = await importOriginal<typeof import('node:os')>()
|
|
39
|
+
return { ...actual, homedir: () => homeState.fakeHome ?? actual.homedir() }
|
|
40
|
+
})
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
43
|
* M3 tool-handler 集成测试。
|
|
34
44
|
*
|
|
35
45
|
* 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
|
|
36
|
-
* 传真实
|
|
46
|
+
* 传真实 `~/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
|
|
37
47
|
*
|
|
38
48
|
* 覆盖全 11 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价,
|
|
39
49
|
* 以及 u8 doctor / u9 归一化 / u10 find 分组 / u11 metadataProvider 缓存 / u12 跨会话检索(各见对应 describe)。
|
|
@@ -46,14 +56,14 @@ import {
|
|
|
46
56
|
// vitest 默认 5s(pnpm extensions:test 全量跑时多包集成测试并发 IO),显式放宽到 60s。
|
|
47
57
|
describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
48
58
|
it('1. find by uuid fragment returns matching session', async () => {
|
|
49
|
-
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, REAL)
|
|
59
|
+
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir: REAL })
|
|
50
60
|
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
51
61
|
expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
|
|
52
62
|
expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
|
|
53
63
|
})
|
|
54
64
|
|
|
55
65
|
it('2. outline yields 32 turns within token budget (v2 O1: <=1500)', async () => {
|
|
56
|
-
const r = await handleSessionRead({ action: 'outline', session: E6 }, REAL)
|
|
66
|
+
const r = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
|
|
57
67
|
const d = r.details as { turns: unknown[]; tokenEstimate: number }
|
|
58
68
|
expect(d.turns.length).toBe(32)
|
|
59
69
|
// v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 600→1500(design §3.3 D4)
|
|
@@ -61,7 +71,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
61
71
|
})
|
|
62
72
|
|
|
63
73
|
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)
|
|
74
|
+
const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, { agentDir: REAL })
|
|
65
75
|
const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
|
|
66
76
|
expect(d.entries.length).toBeGreaterThan(0)
|
|
67
77
|
// v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
|
|
@@ -76,7 +86,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
76
86
|
// 而非失败——与 TC14-TC18 的「数据不存在则 return」守卫模式一致,避免偶发红。
|
|
77
87
|
// 注意必须用 hasAnyRealSession(双目录),hasRealSession 只扫 sessions/ 扫不到 019fe635。
|
|
78
88
|
if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
|
|
79
|
-
const r = await handleSessionRead({ action: 'family', session: FAM }, REAL)
|
|
89
|
+
const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: REAL })
|
|
80
90
|
const d = r.details as {
|
|
81
91
|
forks: Array<{ sessionId: string }>
|
|
82
92
|
subagents: Array<{ sessionId: string }>
|
|
@@ -87,21 +97,28 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
87
97
|
})
|
|
88
98
|
|
|
89
99
|
it('5. search pattern returns hits', async () => {
|
|
90
|
-
const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, REAL)
|
|
100
|
+
const r = await handleSessionRead({ action: 'search', session: E6, pattern: 'plugin' }, { agentDir: REAL })
|
|
91
101
|
const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
|
|
92
102
|
expect(d.hits.length).toBeGreaterThan(0)
|
|
93
103
|
expect(typeof d.hits[0].matchSnippet).toBe('string')
|
|
94
104
|
})
|
|
95
105
|
|
|
96
106
|
it('6. export outline materializes a .md file', async () => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
// fs-guard:export 写目标 = <agentDir>/tmp/,agentDir 传 REAL 会 mkdir 真实 ~/.pi/agent/tmp
|
|
108
|
+
// (白名单外)。读侧经 liveSessionDir=[live] 根仍扫真实数据,写侧落 mkdtemp tmp 自建自删。
|
|
109
|
+
const dir = await mkdtemp(join(tmpdir(), 'tool-handler-export-'))
|
|
110
|
+
try {
|
|
111
|
+
const r = await handleSessionRead(
|
|
112
|
+
{ action: 'export', session: E6, format: 'outline' },
|
|
113
|
+
{ agentDir: dir, liveSessionDir: join(REAL, 'sessions') },
|
|
114
|
+
)
|
|
115
|
+
const d = r.details as { path: string; sizeBytes: number }
|
|
116
|
+
expect(d.path).toMatch(/\.md$/)
|
|
117
|
+
expect(existsSync(d.path)).toBe(true)
|
|
118
|
+
expect(d.sizeBytes).toBeGreaterThan(0)
|
|
119
|
+
} finally {
|
|
120
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
121
|
+
}
|
|
105
122
|
})
|
|
106
123
|
|
|
107
124
|
// w1 合并 subagent 候选后,本机 subagent task 文本(含本用例 query 字符串本身,因当前
|
|
@@ -111,7 +128,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
111
128
|
it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
|
|
112
129
|
const r = await handleSessionRead(
|
|
113
130
|
{ action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
|
|
114
|
-
REAL,
|
|
131
|
+
{ agentDir: REAL },
|
|
115
132
|
)
|
|
116
133
|
const d = r.details as { matches: unknown[]; truncated: boolean }
|
|
117
134
|
expect(d.matches).toEqual([])
|
|
@@ -125,17 +142,17 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
125
142
|
|
|
126
143
|
it('8. F4 detail turn out of range throws with 越界', async () => {
|
|
127
144
|
await expect(
|
|
128
|
-
handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, REAL),
|
|
145
|
+
handleSessionRead({ action: 'detail', session: E6, turns: 'T999' }, { agentDir: REAL }),
|
|
129
146
|
).rejects.toThrow(/越界/)
|
|
130
147
|
})
|
|
131
148
|
|
|
132
149
|
it('9. F5 outline missing session throws naming session', async () => {
|
|
133
|
-
await expect(handleSessionRead({ action: 'outline' }, REAL)).rejects.toThrow(/session/)
|
|
150
|
+
await expect(handleSessionRead({ action: 'outline' }, { agentDir: REAL })).rejects.toThrow(/session/)
|
|
134
151
|
})
|
|
135
152
|
|
|
136
153
|
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)
|
|
154
|
+
const rFull = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
|
|
155
|
+
const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: REAL })
|
|
139
156
|
const dFull = rFull.details as { turns: unknown[] }
|
|
140
157
|
const dFrag = rFrag.details as { turns: unknown[] }
|
|
141
158
|
expect(dFrag.turns.length).toBe(dFull.turns.length)
|
|
@@ -146,7 +163,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
146
163
|
it('user-messages returns 26 user entries with turn + full text', async () => {
|
|
147
164
|
const r = await handleSessionRead(
|
|
148
165
|
{ action: 'extract', session: E6, what: 'user-messages' },
|
|
149
|
-
REAL,
|
|
166
|
+
{ agentDir: REAL },
|
|
150
167
|
)
|
|
151
168
|
const d = r.details as {
|
|
152
169
|
what: string
|
|
@@ -169,7 +186,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
169
186
|
it('commands (no filter) returns 519 tool calls with name + summary', async () => {
|
|
170
187
|
const r = await handleSessionRead(
|
|
171
188
|
{ action: 'extract', session: E6, what: 'commands' },
|
|
172
|
-
REAL,
|
|
189
|
+
{ agentDir: REAL },
|
|
173
190
|
)
|
|
174
191
|
const d = r.details as {
|
|
175
192
|
count: number
|
|
@@ -192,7 +209,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
192
209
|
it('commands tool=bash returns 309 all bash', async () => {
|
|
193
210
|
const r = await handleSessionRead(
|
|
194
211
|
{ action: 'extract', session: E6, what: 'commands', tool: 'bash' },
|
|
195
|
-
REAL,
|
|
212
|
+
{ agentDir: REAL },
|
|
196
213
|
)
|
|
197
214
|
const d = r.details as {
|
|
198
215
|
count: number
|
|
@@ -206,7 +223,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
206
223
|
it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
|
|
207
224
|
const r = await handleSessionRead(
|
|
208
225
|
{ action: 'extract', session: E6, what: 'commands', tool: 'nonexist' },
|
|
209
|
-
REAL,
|
|
226
|
+
{ agentDir: REAL },
|
|
210
227
|
)
|
|
211
228
|
const d = r.details as {
|
|
212
229
|
toolDistribution: Array<{ name: string; count: number }>
|
|
@@ -220,7 +237,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
220
237
|
it('files returns deduped paths with op (read/edit/write/head)', async () => {
|
|
221
238
|
const r = await handleSessionRead(
|
|
222
239
|
{ action: 'extract', session: E6, what: 'files' },
|
|
223
|
-
REAL,
|
|
240
|
+
{ agentDir: REAL },
|
|
224
241
|
)
|
|
225
242
|
const d = r.details as {
|
|
226
243
|
count: number
|
|
@@ -241,7 +258,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
241
258
|
it('commits returns hash list with 7-8 hex + source turn', async () => {
|
|
242
259
|
const r = await handleSessionRead(
|
|
243
260
|
{ action: 'extract', session: E6, what: 'commits' },
|
|
244
|
-
REAL,
|
|
261
|
+
{ agentDir: REAL },
|
|
245
262
|
)
|
|
246
263
|
const d = r.details as {
|
|
247
264
|
count: number
|
|
@@ -260,7 +277,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
260
277
|
it('tool-results (no filter) returns 515 results with toolName + text', async () => {
|
|
261
278
|
const r = await handleSessionRead(
|
|
262
279
|
{ action: 'extract', session: E6, what: 'tool-results' },
|
|
263
|
-
REAL,
|
|
280
|
+
{ agentDir: REAL },
|
|
264
281
|
)
|
|
265
282
|
const d = r.details as {
|
|
266
283
|
count: number
|
|
@@ -277,7 +294,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
277
294
|
it('tool-results tool=bash returns 309 all bash', async () => {
|
|
278
295
|
const r = await handleSessionRead(
|
|
279
296
|
{ action: 'extract', session: E6, what: 'tool-results', tool: 'bash' },
|
|
280
|
-
REAL,
|
|
297
|
+
{ agentDir: REAL },
|
|
281
298
|
)
|
|
282
299
|
const d = r.details as { count: number; items: Array<{ toolName: string }> }
|
|
283
300
|
expect(d.count).toBe(309)
|
|
@@ -287,7 +304,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
287
304
|
it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
|
|
288
305
|
const r = await handleSessionRead(
|
|
289
306
|
{ action: 'extract', session: E6, what: 'tool-results', tool: 'nonexist' },
|
|
290
|
-
REAL,
|
|
307
|
+
{ agentDir: REAL },
|
|
291
308
|
)
|
|
292
309
|
expect(r.content[0].text).toContain('无匹配')
|
|
293
310
|
expect(r.content[0].text).toContain('bash×309')
|
|
@@ -297,8 +314,8 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
297
314
|
// what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
|
|
298
315
|
// isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
|
|
299
316
|
const params: SessionReadParams = { action: 'extract', session: E6 }
|
|
300
|
-
await expect(handleSessionRead(params, REAL)).rejects.toThrow(/无效/)
|
|
301
|
-
await expect(handleSessionRead(params, REAL)).rejects.toThrow(
|
|
317
|
+
await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(/无效/)
|
|
318
|
+
await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(
|
|
302
319
|
/user-messages\/commands\/files\/commits\/tool-results/,
|
|
303
320
|
)
|
|
304
321
|
})
|
|
@@ -309,7 +326,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
309
326
|
try {
|
|
310
327
|
await handleSessionRead(
|
|
311
328
|
{ action: 'extract', session: E6, what: 'user-messages', turns: 'T999' },
|
|
312
|
-
REAL,
|
|
329
|
+
{ agentDir: REAL },
|
|
313
330
|
)
|
|
314
331
|
} catch (e) {
|
|
315
332
|
msg = (e as Error).message
|
|
@@ -358,7 +375,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
|
358
375
|
await makeFixtureSession(dir, ID2, '第二段内容')
|
|
359
376
|
|
|
360
377
|
// outline 走 resolveSessionId → 2 匹配 → F2 消歧(不抛错)
|
|
361
|
-
const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, dir)
|
|
378
|
+
const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, { agentDir: dir })
|
|
362
379
|
const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
|
|
363
380
|
expect(d.ambiguous).toBe(true)
|
|
364
381
|
expect(d.candidates).toHaveLength(2)
|
|
@@ -382,7 +399,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
|
382
399
|
if (action === 'expand') params.turn = 'T001'
|
|
383
400
|
if (action === 'search') params.pattern = 'x'
|
|
384
401
|
if (action === 'extract') params.what = 'user-messages'
|
|
385
|
-
const r = await handleSessionRead(params, dir)
|
|
402
|
+
const r = await handleSessionRead(params, { agentDir: dir })
|
|
386
403
|
const d = r.details as { ambiguous: boolean }
|
|
387
404
|
expect(d.ambiguous, `action=${action}`).toBe(true)
|
|
388
405
|
expect(r.content[0].text).toContain('👉')
|
|
@@ -417,7 +434,7 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
|
|
|
417
434
|
]
|
|
418
435
|
await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
|
|
419
436
|
|
|
420
|
-
const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
|
|
437
|
+
const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
|
|
421
438
|
const d = r.details as { stats: { skippedLines: number } }
|
|
422
439
|
expect(d.stats.skippedLines).toBe(1)
|
|
423
440
|
expect(r.content[0].text).toContain('1 skipped lines')
|
|
@@ -425,13 +442,84 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
|
|
|
425
442
|
|
|
426
443
|
it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
|
|
427
444
|
await makeFixtureSession(dir, SID, 'clean session')
|
|
428
|
-
const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
|
|
445
|
+
const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
|
|
429
446
|
const d = r.details as { stats: { skippedLines: number } }
|
|
430
447
|
expect(d.stats.skippedLines).toBe(0)
|
|
431
448
|
expect(r.content[0].text).not.toContain('skipped lines')
|
|
432
449
|
})
|
|
433
450
|
})
|
|
434
451
|
|
|
452
|
+
// ============================================================
|
|
453
|
+
// E7:行渲染统一(ext-simplify-04 U5,design D3)。outline 与 export {format:"outline"}
|
|
454
|
+
// 共用 renderOutline 返回的渲染行(OutlineResult.lines)+ 同一尾段拼装——预算度量 =
|
|
455
|
+
// 展示 by construction;旁支标记统一为 `[旁支 N entries]`(改前 handler 侧第二份行
|
|
456
|
+
// 格式知识输出裸 `[旁支]`,已随 formatOutlineText 删除)。
|
|
457
|
+
// ============================================================
|
|
458
|
+
describe('E7 行渲染统一(outline 与 export outline 同一渲染行)', () => {
|
|
459
|
+
let dir: string
|
|
460
|
+
const SID = '019e6c96-bbbb-cccc-dddd-00000000000e'
|
|
461
|
+
|
|
462
|
+
/** 写含 fork 旁支的 session:主链 R→m1(user)→m2(assistant),b1/b2 挂 m1 下成
|
|
463
|
+
* 旁支子树(buildTreeView:branches[m1]=2;segmentTurns:单 turn [m1, m2],
|
|
464
|
+
* 旁支 entry 不进 leaf 视图)。 */
|
|
465
|
+
async function writeForkSession(): Promise<void> {
|
|
466
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
467
|
+
const msg = (id: string, parentId: string, role: 'user' | 'assistant', text: string) =>
|
|
468
|
+
JSON.stringify({
|
|
469
|
+
type: 'message',
|
|
470
|
+
id: `${SID}-${id}`,
|
|
471
|
+
parentId,
|
|
472
|
+
message: { role, content: [{ type: 'text', text }] },
|
|
473
|
+
})
|
|
474
|
+
const lines = [
|
|
475
|
+
JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
|
|
476
|
+
msg('m1', SID, 'user', 'fork point turn'),
|
|
477
|
+
msg('b1', `${SID}-m1`, 'assistant', 'branch one'),
|
|
478
|
+
msg('b2', `${SID}-m1`, 'assistant', 'branch two'),
|
|
479
|
+
msg('m2', `${SID}-m1`, 'assistant', 'main line answer'),
|
|
480
|
+
]
|
|
481
|
+
await writeFile(join(dir, 'sessions', '--demo-cwd--', `${SID}.jsonl`), lines.join('\n') + '\n')
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
beforeEach(async () => {
|
|
485
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-e7-'))
|
|
486
|
+
})
|
|
487
|
+
afterEach(async () => {
|
|
488
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('① allBranches:true → 旁支行标记 `[旁支 N entries]`(N=forkPoint 旁支子树 entry 数)+ 尾段同改前语义', async () => {
|
|
492
|
+
await writeForkSession()
|
|
493
|
+
const r = await handleSessionRead(
|
|
494
|
+
{ action: 'outline', session: SID, allBranches: true },
|
|
495
|
+
{ agentDir: dir },
|
|
496
|
+
)
|
|
497
|
+
const text = r.content[0].text
|
|
498
|
+
// 旁支标记带 entry 数;裸 `[旁支]` 旧形态零出现('[旁支]' 与 '[旁支 2 entries]' 互不为子串)
|
|
499
|
+
expect(text).toContain('T000 · fork point turn · → main line answer · [旁支 2 entries]')
|
|
500
|
+
expect(text).not.toContain('[旁支]')
|
|
501
|
+
// stats 尾段与改前语义一致(totalEntries = leaf 2 + 旁支 2)
|
|
502
|
+
expect(text).toContain('1 turns · 4 entries · ')
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('② 两 action 一致性:export {format:"outline"} 物化全文 === outline content 文本(旁支行同形态)', async () => {
|
|
506
|
+
await writeForkSession()
|
|
507
|
+
const outline = await handleSessionRead(
|
|
508
|
+
{ action: 'outline', session: SID, allBranches: true },
|
|
509
|
+
{ agentDir: dir },
|
|
510
|
+
)
|
|
511
|
+
const exp = await handleSessionRead(
|
|
512
|
+
{ action: 'export', session: SID, format: 'outline', allBranches: true },
|
|
513
|
+
{ agentDir: dir },
|
|
514
|
+
)
|
|
515
|
+
const d = exp.details as { path: string }
|
|
516
|
+
const exported = await readFile(d.path, 'utf8')
|
|
517
|
+
// 干净 session(无坏行)下两 action 全文逐字节一致——同一 result.lines + 同一尾段拼装
|
|
518
|
+
expect(exported).toBe(outline.content[0].text)
|
|
519
|
+
expect(exported).toContain('[旁支 2 entries]')
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
|
|
435
523
|
describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
|
|
436
524
|
let dir: string
|
|
437
525
|
const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
|
|
@@ -445,21 +533,21 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
445
533
|
})
|
|
446
534
|
|
|
447
535
|
it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
|
|
448
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
|
|
536
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
|
|
449
537
|
const d = r.details as { hits: unknown[] }
|
|
450
538
|
// 字面量 '(a+)+' 不在内容里 → 0 命中(若按正则执行会命中 'aaa' 且可能指数回溯)
|
|
451
539
|
expect(d.hits).toHaveLength(0)
|
|
452
540
|
})
|
|
453
541
|
|
|
454
542
|
it('普通正则仍按正则匹配', async () => {
|
|
455
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, dir)
|
|
543
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, { agentDir: dir })
|
|
456
544
|
const d = r.details as { hits: unknown[] }
|
|
457
545
|
expect(d.hits.length).toBeGreaterThan(0)
|
|
458
546
|
})
|
|
459
547
|
|
|
460
548
|
it('范围量词 {m,n} 形态 → 降级字面子串((a{1,3})*、(a{0,2})*、(a{1,3}){2,},MF-1 回归)', async () => {
|
|
461
549
|
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)
|
|
550
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
|
|
463
551
|
const d = r.details as { hits: unknown[] }
|
|
464
552
|
// 字面量不含这些 pattern → 0 命中;若按正则执行会指数回溯挂死
|
|
465
553
|
expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
|
|
@@ -468,7 +556,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
468
556
|
|
|
469
557
|
it('alternation/嵌套量词分支 (a|aa)+、(a*)* → 降级字面子串(MF-5 分支回归)', async () => {
|
|
470
558
|
for (const pattern of ['(a|aa)+', '(a*)*']) {
|
|
471
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, dir)
|
|
559
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
|
|
472
560
|
const d = r.details as { hits: unknown[] }
|
|
473
561
|
expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
|
|
474
562
|
}
|
|
@@ -477,7 +565,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
477
565
|
it('(a{1,3}) 单独使用不被降级(组后无尾随量词,仍按正则执行)', async () => {
|
|
478
566
|
const r = await handleSessionRead(
|
|
479
567
|
{ action: 'search', session: SID, pattern: '(a{1,3})' },
|
|
480
|
-
dir,
|
|
568
|
+
{ agentDir: dir },
|
|
481
569
|
)
|
|
482
570
|
const d = r.details as { hits: unknown[] }
|
|
483
571
|
// 按正则执行命中 'aaa' → >0 命中(若被降级为字面量则 0 命中)
|
|
@@ -485,12 +573,12 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
485
573
|
})
|
|
486
574
|
|
|
487
575
|
it('降级标注:header 含「已降级为字面子串匹配」(S-3)', async () => {
|
|
488
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, dir)
|
|
576
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
|
|
489
577
|
expect(r.content[0].text).toContain('已降级为字面子串匹配')
|
|
490
578
|
})
|
|
491
579
|
|
|
492
580
|
it('非法正则 pattern → 字面子串兜底不抛错(零命中,S-6)', async () => {
|
|
493
|
-
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, dir)
|
|
581
|
+
const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, { agentDir: dir })
|
|
494
582
|
const d = r.details as { hits: unknown[] }
|
|
495
583
|
expect(d.hits).toHaveLength(0)
|
|
496
584
|
})
|
|
@@ -498,7 +586,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
498
586
|
it('scope 过滤:scope=assistant 零命中(fixture 仅 user 角色,S-6)', async () => {
|
|
499
587
|
const r = await handleSessionRead(
|
|
500
588
|
{ action: 'search', session: SID, pattern: 'aaa', scope: 'assistant' },
|
|
501
|
-
dir,
|
|
589
|
+
{ agentDir: dir },
|
|
502
590
|
)
|
|
503
591
|
const d = r.details as { hits: unknown[] }
|
|
504
592
|
expect(d.hits).toHaveLength(0)
|
|
@@ -525,7 +613,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
525
613
|
)
|
|
526
614
|
const r = await handleSessionRead(
|
|
527
615
|
{ action: 'search', session: sid2, pattern: 'aa', limit: 2 },
|
|
528
|
-
dir,
|
|
616
|
+
{ agentDir: dir },
|
|
529
617
|
)
|
|
530
618
|
const d = r.details as { hits: unknown[]; truncated: boolean }
|
|
531
619
|
expect(d.truncated).toBe(true)
|
|
@@ -536,7 +624,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
536
624
|
const ac = new AbortController()
|
|
537
625
|
ac.abort()
|
|
538
626
|
await expect(
|
|
539
|
-
handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, dir, ac.signal),
|
|
627
|
+
handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, { agentDir: dir }, ac.signal),
|
|
540
628
|
).rejects.toThrow(/中断/)
|
|
541
629
|
})
|
|
542
630
|
})
|
|
@@ -571,7 +659,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
571
659
|
message: {
|
|
572
660
|
role: 'assistant',
|
|
573
661
|
content: [
|
|
574
|
-
{ type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '
|
|
662
|
+
{ type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: join(tmpdir(), 'log.txt') } },
|
|
575
663
|
],
|
|
576
664
|
},
|
|
577
665
|
}),
|
|
@@ -595,7 +683,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
595
683
|
message: {
|
|
596
684
|
role: 'assistant',
|
|
597
685
|
content: [
|
|
598
|
-
{ type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: '
|
|
686
|
+
{ type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: join(tmpdir(), 'out.txt') } },
|
|
599
687
|
],
|
|
600
688
|
},
|
|
601
689
|
}),
|
|
@@ -611,7 +699,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
611
699
|
},
|
|
612
700
|
}),
|
|
613
701
|
])
|
|
614
|
-
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
|
|
702
|
+
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
|
|
615
703
|
const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
|
|
616
704
|
const hashes = d.items.map((it) => it.hash)
|
|
617
705
|
expect(hashes).toContain('abc1234f')
|
|
@@ -652,7 +740,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
652
740
|
message: {
|
|
653
741
|
role: 'assistant',
|
|
654
742
|
content: [
|
|
655
|
-
{ type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '
|
|
743
|
+
{ type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: join(tmpdir(), 'deploy.txt') } },
|
|
656
744
|
],
|
|
657
745
|
},
|
|
658
746
|
}),
|
|
@@ -669,7 +757,7 @@ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-conte
|
|
|
669
757
|
},
|
|
670
758
|
}),
|
|
671
759
|
])
|
|
672
|
-
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
|
|
760
|
+
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
|
|
673
761
|
const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
|
|
674
762
|
const dupes = d.items.filter((it) => it.hash === 'def5678a')
|
|
675
763
|
expect(dupes).toHaveLength(1)
|
|
@@ -811,7 +899,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
811
899
|
// export 的 details.path = session-view-<sessionId>.md,含 header 真实 id,不含文件名
|
|
812
900
|
const r = await handleSessionRead(
|
|
813
901
|
{ action: 'export', session: filePath, format: 'outline' },
|
|
814
|
-
dir,
|
|
902
|
+
{ agentDir: dir },
|
|
815
903
|
)
|
|
816
904
|
const d = r.details as { path: string }
|
|
817
905
|
expect(d.path).toContain(fileId)
|
|
@@ -826,10 +914,10 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
826
914
|
`not-exist-${Date.now()}.jsonl`,
|
|
827
915
|
)
|
|
828
916
|
await expect(
|
|
829
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
917
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
830
918
|
).rejects.toThrow(/读取失败.*文件不存在/)
|
|
831
919
|
await expect(
|
|
832
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
920
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
833
921
|
).rejects.toThrow('👉')
|
|
834
922
|
})
|
|
835
923
|
|
|
@@ -838,7 +926,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
838
926
|
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
839
927
|
await writeFile(filePath, 'not jsonl')
|
|
840
928
|
await expect(
|
|
841
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
929
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
842
930
|
).rejects.toThrow(/读取失败.*非 \.jsonl/)
|
|
843
931
|
})
|
|
844
932
|
|
|
@@ -847,7 +935,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
847
935
|
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
848
936
|
await writeFile(filePath, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
|
|
849
937
|
await expect(
|
|
850
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
938
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
851
939
|
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
852
940
|
})
|
|
853
941
|
|
|
@@ -856,29 +944,38 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
856
944
|
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
857
945
|
await writeFile(filePath, '')
|
|
858
946
|
await expect(
|
|
859
|
-
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
947
|
+
handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
|
|
860
948
|
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
861
949
|
})
|
|
862
950
|
|
|
863
951
|
it('TC6: ~ 前缀展开到 homedir(文件实际在 homedir 下)', async () => {
|
|
864
|
-
|
|
865
|
-
|
|
952
|
+
// fs-guard:真实 homedir 根在白名单外(禁止在用户主目录根散落 .sr-w2-test-*)——
|
|
953
|
+
// homedir 经文件头 vi.mock 指到 mkdtemp tmp(白名单内),「~ 前缀展开」断言语义不变
|
|
954
|
+
const fakeHome = await mkdtemp(join(tmpdir(), 'tool-handler-tc6-home-'))
|
|
955
|
+
homeState.fakeHome = fakeHome
|
|
866
956
|
try {
|
|
867
|
-
const
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
957
|
+
const home = homedir()
|
|
958
|
+
const tmpUnderHome = await mkdtemp(join(home, '.sr-w2-test-'))
|
|
959
|
+
try {
|
|
960
|
+
const fileId = '019e6c96-dddd-eeee-ffff-0000000006c1'
|
|
961
|
+
const sessionFile = join(tmpUnderHome, 's.jsonl')
|
|
962
|
+
await writeFile(
|
|
963
|
+
sessionFile,
|
|
964
|
+
JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }) + '\n',
|
|
965
|
+
)
|
|
966
|
+
// ~/开头的相对 homedir 路径
|
|
967
|
+
const tildePath = '~/' + sessionFile.slice(home.length + 1)
|
|
968
|
+
const r = await handleSessionRead(
|
|
969
|
+
{ action: 'export', session: tildePath, format: 'outline' },
|
|
970
|
+
{ agentDir: dir },
|
|
971
|
+
)
|
|
972
|
+
expect((r.details as { path: string }).path).toContain(fileId)
|
|
973
|
+
} finally {
|
|
974
|
+
await rm(tmpUnderHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
975
|
+
}
|
|
880
976
|
} finally {
|
|
881
|
-
|
|
977
|
+
homeState.fakeHome = undefined
|
|
978
|
+
await rm(fakeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
882
979
|
}
|
|
883
980
|
})
|
|
884
981
|
})
|
|
@@ -898,7 +995,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
898
995
|
await makeFixtureSubagent(dir, 'sa-aaa', { realSessionId: realId, firstUserText: 'do task' })
|
|
899
996
|
const r = await handleSessionRead(
|
|
900
997
|
{ action: 'export', session: 'sa-aaa', format: 'outline' },
|
|
901
|
-
dir,
|
|
998
|
+
{ agentDir: dir },
|
|
902
999
|
)
|
|
903
1000
|
const d = r.details as { path: string }
|
|
904
1001
|
expect(d.path).toContain(realId)
|
|
@@ -913,11 +1010,11 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
913
1010
|
sessionFileExists: false,
|
|
914
1011
|
})
|
|
915
1012
|
await expect(
|
|
916
|
-
handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir),
|
|
1013
|
+
handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir }),
|
|
917
1014
|
).rejects.toThrow('session 文件不存在')
|
|
918
1015
|
// 错误含 manifest 全部元数据 + 👉
|
|
919
1016
|
try {
|
|
920
|
-
await handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir)
|
|
1017
|
+
await handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir })
|
|
921
1018
|
} catch (e) {
|
|
922
1019
|
const msg = (e as Error).message
|
|
923
1020
|
expect(msg).toContain('sa-gc')
|
|
@@ -931,10 +1028,10 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
931
1028
|
|
|
932
1029
|
it('TC9: sa-id 0 命中(可能 running)→ ES2(含 family 指引 + 完整 id 提示 + 👉)', async () => {
|
|
933
1030
|
await expect(
|
|
934
|
-
handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir),
|
|
1031
|
+
handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
|
|
935
1032
|
).rejects.toThrow('无匹配 record')
|
|
936
1033
|
try {
|
|
937
|
-
await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
|
|
1034
|
+
await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
|
|
938
1035
|
} catch (e) {
|
|
939
1036
|
const msg = (e as Error).message
|
|
940
1037
|
expect(msg).toContain('可能尚未落盘')
|
|
@@ -949,7 +1046,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
949
1046
|
})
|
|
950
1047
|
// 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中 → ES2
|
|
951
1048
|
await expect(
|
|
952
|
-
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, dir),
|
|
1049
|
+
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
|
|
953
1050
|
).rejects.toThrow('无匹配 record')
|
|
954
1051
|
})
|
|
955
1052
|
|
|
@@ -978,12 +1075,12 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
978
1075
|
)
|
|
979
1076
|
}
|
|
980
1077
|
await expect(
|
|
981
|
-
handleSessionRead({ action: 'outline', session: 'sa-dup' }, dir),
|
|
1078
|
+
handleSessionRead({ action: 'outline', session: 'sa-dup' }, { agentDir: dir }),
|
|
982
1079
|
).rejects.toThrow(/匹配 2 个 record.*数据异常/)
|
|
983
1080
|
})
|
|
984
1081
|
|
|
985
1082
|
it('CQ3: sa-id 命中但 sessionFile header 读不出 → F6 风格(不降级 record.id 当 sessionId)', async () => {
|
|
986
|
-
// sessionFile 存在但首行非 session header →
|
|
1083
|
+
// sessionFile 存在但首行非 session header → readSessionHeaderIdSync 返 undefined
|
|
987
1084
|
const slug = '--demo-cwd--'
|
|
988
1085
|
const sessionFile = join(dir, 'subagents', slug, 'sessions', 'bad.jsonl')
|
|
989
1086
|
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
@@ -1001,7 +1098,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
1001
1098
|
)
|
|
1002
1099
|
// 抛 F6 风格(读取失败 + 首行非合法 session header),不降级返回 sa-bad 当 sessionId
|
|
1003
1100
|
await expect(
|
|
1004
|
-
handleSessionRead({ action: 'outline', session: 'sa-bad' }, dir),
|
|
1101
|
+
handleSessionRead({ action: 'outline', session: 'sa-bad' }, { agentDir: dir }),
|
|
1005
1102
|
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
1006
1103
|
})
|
|
1007
1104
|
})
|
|
@@ -1026,7 +1123,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1026
1123
|
// 无 source → 两者
|
|
1027
1124
|
const rBoth = await handleSessionRead(
|
|
1028
1125
|
{ action: 'find', query: sharedFragment },
|
|
1029
|
-
dir,
|
|
1126
|
+
{ agentDir: dir },
|
|
1030
1127
|
)
|
|
1031
1128
|
const dBoth = rBoth.details as {
|
|
1032
1129
|
matches: Array<{ source: string; sessionId: string }>
|
|
@@ -1037,7 +1134,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1037
1134
|
// source:subagent → 只 sub
|
|
1038
1135
|
const rSub = await handleSessionRead(
|
|
1039
1136
|
{ action: 'find', query: sharedFragment, source: 'subagent' },
|
|
1040
|
-
dir,
|
|
1137
|
+
{ agentDir: dir },
|
|
1041
1138
|
)
|
|
1042
1139
|
const dSub = rSub.details as {
|
|
1043
1140
|
matches: Array<{ source: string; sessionId: string }>
|
|
@@ -1049,7 +1146,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1049
1146
|
// source:main → 只 main
|
|
1050
1147
|
const rMain = await handleSessionRead(
|
|
1051
1148
|
{ action: 'find', query: sharedFragment, source: 'main' },
|
|
1052
|
-
dir,
|
|
1149
|
+
{ agentDir: dir },
|
|
1053
1150
|
)
|
|
1054
1151
|
const dMain = rMain.details as {
|
|
1055
1152
|
matches: Array<{ source: string; sessionId: string }>
|
|
@@ -1069,14 +1166,14 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1069
1166
|
// 无 source → main+sub 共享片段 → 2 匹配 → F2 消歧
|
|
1070
1167
|
const rMulti = await handleSessionRead(
|
|
1071
1168
|
{ action: 'outline', session: sharedFragment },
|
|
1072
|
-
dir,
|
|
1169
|
+
{ agentDir: dir },
|
|
1073
1170
|
)
|
|
1074
1171
|
expect((rMulti.details as { ambiguous: boolean }).ambiguous).toBe(true)
|
|
1075
1172
|
|
|
1076
1173
|
// source:main → 收窄到 main → 唯一匹配 → outline 成功,且是 main(export path 含 mainId)
|
|
1077
1174
|
const rExp = await handleSessionRead(
|
|
1078
1175
|
{ action: 'export', session: sharedFragment, source: 'main', format: 'outline' },
|
|
1079
|
-
dir,
|
|
1176
|
+
{ agentDir: dir },
|
|
1080
1177
|
)
|
|
1081
1178
|
expect((rExp.details as { path: string }).path).toContain(mainId)
|
|
1082
1179
|
})
|
|
@@ -1087,7 +1184,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1087
1184
|
const manifests = await listRecordManifests(REAL)
|
|
1088
1185
|
const alive = manifests.filter((m) => existsSync(m.sessionFile))
|
|
1089
1186
|
if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
|
|
1090
|
-
const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, REAL)
|
|
1187
|
+
const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, { agentDir: REAL })
|
|
1091
1188
|
const d = r.details as { turns: unknown[] }
|
|
1092
1189
|
expect(d.turns.length).toBeGreaterThan(0)
|
|
1093
1190
|
})
|
|
@@ -1098,7 +1195,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1098
1195
|
(m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
|
|
1099
1196
|
)
|
|
1100
1197
|
if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
|
|
1101
|
-
const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, REAL)
|
|
1198
|
+
const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, { agentDir: REAL })
|
|
1102
1199
|
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
1103
1200
|
})
|
|
1104
1201
|
|
|
@@ -1109,7 +1206,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1109
1206
|
// 绝对路径形态直接读(M0 入口不依赖 findSessions)
|
|
1110
1207
|
const r = await handleSessionRead(
|
|
1111
1208
|
{ action: 'outline', session: alive[0].sessionFile },
|
|
1112
|
-
REAL,
|
|
1209
|
+
{ agentDir: REAL },
|
|
1113
1210
|
)
|
|
1114
1211
|
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
1115
1212
|
})
|
|
@@ -1119,14 +1216,14 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
|
|
|
1119
1216
|
const gc = manifests.filter((m) => !existsSync(m.sessionFile))
|
|
1120
1217
|
if (gc.length === 0) return // 本机无 GC 数据则跳过
|
|
1121
1218
|
await expect(
|
|
1122
|
-
handleSessionRead({ action: 'outline', session: gc[0].id }, REAL),
|
|
1219
|
+
handleSessionRead({ action: 'outline', session: gc[0].id }, { agentDir: REAL }),
|
|
1123
1220
|
).rejects.toThrow('session 文件不存在')
|
|
1124
1221
|
})
|
|
1125
1222
|
|
|
1126
1223
|
it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
|
|
1127
1224
|
const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
|
|
1128
1225
|
await expect(
|
|
1129
|
-
handleSessionRead({ action: 'outline', session: fakeId }, REAL),
|
|
1226
|
+
handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
|
|
1130
1227
|
).rejects.toThrow('无匹配 record')
|
|
1131
1228
|
})
|
|
1132
1229
|
}, 60000)
|
|
@@ -1258,7 +1355,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1258
1355
|
])
|
|
1259
1356
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-single-1', path: wfPath })
|
|
1260
1357
|
|
|
1261
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1358
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1262
1359
|
const d = r.details as {
|
|
1263
1360
|
runs: Array<{
|
|
1264
1361
|
runId: string
|
|
@@ -1296,7 +1393,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1296
1393
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-1', path: wf1 })
|
|
1297
1394
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-2', path: wf2 })
|
|
1298
1395
|
|
|
1299
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1396
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1300
1397
|
const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
|
|
1301
1398
|
expect(d.runs).toHaveLength(2)
|
|
1302
1399
|
expect(d.runIds).toHaveLength(2)
|
|
@@ -1321,7 +1418,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1321
1418
|
|
|
1322
1419
|
const r = await handleSessionRead(
|
|
1323
1420
|
{ action: 'workflow', session: WF_ROOT, runId: 'wf-filter-1' },
|
|
1324
|
-
dir,
|
|
1421
|
+
{ agentDir: dir },
|
|
1325
1422
|
)
|
|
1326
1423
|
const d = r.details as {
|
|
1327
1424
|
runs: Array<{ runId: string }>
|
|
@@ -1350,7 +1447,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1350
1447
|
|
|
1351
1448
|
const r = await handleSessionRead(
|
|
1352
1449
|
{ action: 'workflow', session: WF_ROOT, runId: 'wf-nonexist' },
|
|
1353
|
-
dir,
|
|
1450
|
+
{ agentDir: dir },
|
|
1354
1451
|
)
|
|
1355
1452
|
const d = r.details as { runs: unknown[]; runIds: string[]; requestedRunId?: string }
|
|
1356
1453
|
expect(d.runs).toEqual([])
|
|
@@ -1369,7 +1466,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1369
1466
|
await wfMainSession(dir, slug, WF_ROOT)
|
|
1370
1467
|
// 无 wf-link(session 存在但未发起 workflow)
|
|
1371
1468
|
|
|
1372
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1469
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1373
1470
|
const d = r.details as { runs: unknown[]; runIds: unknown[]; sessionId?: string }
|
|
1374
1471
|
expect(d.runs).toEqual([])
|
|
1375
1472
|
expect(d.runIds).toEqual([])
|
|
@@ -1391,7 +1488,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1391
1488
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-1', path: ghostPath })
|
|
1392
1489
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-2', path: wf2 })
|
|
1393
1490
|
|
|
1394
|
-
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1491
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1395
1492
|
const d = r.details as {
|
|
1396
1493
|
runs: Array<{ runId: string }>
|
|
1397
1494
|
runIds: string[]
|
|
@@ -1421,7 +1518,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1421
1518
|
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-jump-1', path: wfPath })
|
|
1422
1519
|
|
|
1423
1520
|
// 第一次:workflow 概览,拿 call sessionId
|
|
1424
|
-
const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1521
|
+
const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1425
1522
|
const dWf = rWf.details as {
|
|
1426
1523
|
runs: Array<{ steps: Array<{ sessionId: string; sessionFile: string }> }>
|
|
1427
1524
|
}
|
|
@@ -1432,7 +1529,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1432
1529
|
// 第二次:用 call sessionId 调 outline(m0 resolveSessionId 三形态复用)
|
|
1433
1530
|
const rOutline = await handleSessionRead(
|
|
1434
1531
|
{ action: 'outline', session: callSessionId },
|
|
1435
|
-
dir,
|
|
1532
|
+
{ agentDir: dir },
|
|
1436
1533
|
)
|
|
1437
1534
|
const dOutline = rOutline.details as { turns: unknown[] }
|
|
1438
1535
|
expect(dOutline.turns.length).toBeGreaterThan(0)
|
|
@@ -1469,7 +1566,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1469
1566
|
{ flag: 'a' },
|
|
1470
1567
|
)
|
|
1471
1568
|
|
|
1472
|
-
const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, dir)
|
|
1569
|
+
const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, { agentDir: dir })
|
|
1473
1570
|
const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
|
|
1474
1571
|
expect(d.runs).toHaveLength(1)
|
|
1475
1572
|
expect(d.runs[0].runId).toBe('wf-sub-1')
|
|
@@ -1490,7 +1587,7 @@ describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
|
|
|
1490
1587
|
it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
|
|
1491
1588
|
const r = await handleSessionRead(
|
|
1492
1589
|
{ action: 'workflow', session: REAL_WF_SESSION },
|
|
1493
|
-
REAL,
|
|
1590
|
+
{ agentDir: REAL },
|
|
1494
1591
|
)
|
|
1495
1592
|
const d = r.details as {
|
|
1496
1593
|
runs: Array<{ runId: string; status: string; steps: unknown[] }>
|
|
@@ -1529,7 +1626,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1529
1626
|
})
|
|
1530
1627
|
|
|
1531
1628
|
it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
|
|
1532
|
-
const r = await handleSessionRead({ action: 'family', session: MAIN }, dir)
|
|
1629
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1533
1630
|
// details 是 Family 对象(root/subagents/workflows),非 { tree }
|
|
1534
1631
|
const d = r.details as {
|
|
1535
1632
|
root: { sessionId: string }
|
|
@@ -1547,7 +1644,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1547
1644
|
it('TC-m3b-dofamily-recursive-true:recursive=true → ExecutionTree(details.tree)', async () => {
|
|
1548
1645
|
const r = await handleSessionRead(
|
|
1549
1646
|
{ action: 'family', session: MAIN, recursive: true },
|
|
1550
|
-
dir,
|
|
1647
|
+
{ agentDir: dir },
|
|
1551
1648
|
)
|
|
1552
1649
|
const d = r.details as {
|
|
1553
1650
|
tree: {
|
|
@@ -1573,6 +1670,142 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1573
1670
|
})
|
|
1574
1671
|
})
|
|
1575
1672
|
|
|
1673
|
+
// ===========================================================================
|
|
1674
|
+
// ext-simplify-04 U4/E2(D2②):formatFamilyText subagents 行富字段展示
|
|
1675
|
+
// status 终态短标签 + agent 名 + task 截 60 摘要;孤儿只标 [已清理];P4 输出量级守卫。
|
|
1676
|
+
// ===========================================================================
|
|
1677
|
+
|
|
1678
|
+
describe('formatFamilyText subagents 行富字段(ext-simplify-04 U4 / E2)', () => {
|
|
1679
|
+
const MAIN = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f1'
|
|
1680
|
+
const SUB = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f2'
|
|
1681
|
+
let dir: string
|
|
1682
|
+
|
|
1683
|
+
beforeEach(async () => {
|
|
1684
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-family-rich-'))
|
|
1685
|
+
await makeFixtureSession(dir, MAIN, 'main session')
|
|
1686
|
+
})
|
|
1687
|
+
afterEach(async () => {
|
|
1688
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1691
|
+
/** alive subagent:session 文件(header + 可选 identity 尾行)+ 可选 manifest 富字段 */
|
|
1692
|
+
async function writeSubagent(
|
|
1693
|
+
realId: string,
|
|
1694
|
+
opts: {
|
|
1695
|
+
manifest?: { agentName?: string; task?: string; slug?: string; model?: string; status?: string }
|
|
1696
|
+
identityTask?: string
|
|
1697
|
+
identityAgent?: string
|
|
1698
|
+
} = {},
|
|
1699
|
+
): Promise<void> {
|
|
1700
|
+
const sessionDir = join(dir, 'subagents', '--demo-cwd--', 'sessions')
|
|
1701
|
+
await mkdir(sessionDir, { recursive: true })
|
|
1702
|
+
const subPath = join(sessionDir, `${realId}.jsonl`)
|
|
1703
|
+
const lines = [JSON.stringify({ type: 'session', id: realId, cwd: '/demo' })]
|
|
1704
|
+
if (opts.identityTask !== undefined || opts.identityAgent !== undefined) {
|
|
1705
|
+
lines.push(
|
|
1706
|
+
JSON.stringify({
|
|
1707
|
+
type: 'custom',
|
|
1708
|
+
customType: 'subagent-identity',
|
|
1709
|
+
data: {
|
|
1710
|
+
id: `sa-${realId.slice(0, 8)}`,
|
|
1711
|
+
rootSessionId: MAIN,
|
|
1712
|
+
slug: 'identity-slug',
|
|
1713
|
+
agent: opts.identityAgent ?? 'explorer',
|
|
1714
|
+
task: opts.identityTask ?? 't',
|
|
1715
|
+
},
|
|
1716
|
+
}),
|
|
1717
|
+
)
|
|
1718
|
+
}
|
|
1719
|
+
await writeFile(subPath, lines.join('\n') + '\n')
|
|
1720
|
+
if (opts.manifest) {
|
|
1721
|
+
const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
|
|
1722
|
+
await mkdir(recordsDir, { recursive: true })
|
|
1723
|
+
await writeFile(
|
|
1724
|
+
join(recordsDir, `sa-${realId}.json`),
|
|
1725
|
+
JSON.stringify({
|
|
1726
|
+
id: `sa-${realId}`,
|
|
1727
|
+
rootSessionId: MAIN,
|
|
1728
|
+
sessionFile: subPath,
|
|
1729
|
+
...opts.manifest,
|
|
1730
|
+
}),
|
|
1731
|
+
)
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** 孤儿 manifest:sessionFile 指向不存在路径(模拟 .jsonl 被 GC) */
|
|
1736
|
+
async function writeOrphanManifest(
|
|
1737
|
+
saId: string,
|
|
1738
|
+
fields: { agentName?: string; task?: string; slug?: string; status?: string } = {},
|
|
1739
|
+
): Promise<void> {
|
|
1740
|
+
const recordsDir = join(dir, 'subagents', '--demo-cwd--', 'records')
|
|
1741
|
+
await mkdir(recordsDir, { recursive: true })
|
|
1742
|
+
await writeFile(
|
|
1743
|
+
join(recordsDir, `${saId}.json`),
|
|
1744
|
+
JSON.stringify({ id: saId, rootSessionId: MAIN, sessionFile: '/nonexistent/gc.jsonl', ...fields }),
|
|
1745
|
+
)
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
it('manifest 主路径:subagents 行含 [status] agent 名 · task 摘要;root 段不变', async () => {
|
|
1749
|
+
await writeSubagent(SUB, {
|
|
1750
|
+
manifest: { agentName: 'coder', task: '修复登录表单校验并补测试', slug: 'fix-login', status: 'completed' },
|
|
1751
|
+
})
|
|
1752
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1753
|
+
const text = r.content[0].text
|
|
1754
|
+
// 富字段行形态(设计 §3.1):slug 后接 [status] agent · task 摘要
|
|
1755
|
+
expect(text).toContain('slug=fix-login [completed] coder · 修复登录表单校验并补测试')
|
|
1756
|
+
// root/parents/forks/workflows 段不变(root 行含完整 id + 日期括号)
|
|
1757
|
+
expect(text).toContain(`root: ${MAIN} (`)
|
|
1758
|
+
})
|
|
1759
|
+
|
|
1760
|
+
it('P4 输出量级:task 截 60 字符(超出加省略号),换行压平不破坏行结构', async () => {
|
|
1761
|
+
const SUB2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f3'
|
|
1762
|
+
await writeSubagent(SUB, {
|
|
1763
|
+
manifest: { agentName: 'coder', task: 'A'.repeat(100), slug: 'long-task', status: 'running' },
|
|
1764
|
+
})
|
|
1765
|
+
await writeSubagent(SUB2, {
|
|
1766
|
+
manifest: { agentName: 'worker', task: 'line1\nline2\ttabbed', slug: 'nl-task', status: 'failed' },
|
|
1767
|
+
})
|
|
1768
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1769
|
+
const text = r.content[0].text
|
|
1770
|
+
// 截断:恰 60 字符 + 省略号,原文 61+ 连续段不出现
|
|
1771
|
+
expect(text).toContain(`${'A'.repeat(60)}…`)
|
|
1772
|
+
expect(text).not.toContain('A'.repeat(61))
|
|
1773
|
+
// 换行/制表压平为单空格
|
|
1774
|
+
expect(text).toContain('slug=nl-task [failed] worker · line1 line2 tabbed')
|
|
1775
|
+
// 行结构:subagents 段 = 头行 + 每 subagent 恰一行(换行未折叠出额外行)
|
|
1776
|
+
const lines = text.split('\n')
|
|
1777
|
+
const headIdx = lines.indexOf('subagents:')
|
|
1778
|
+
const subLines = lines.slice(headIdx + 1)
|
|
1779
|
+
expect(subLines).toHaveLength(2)
|
|
1780
|
+
expect(subLines.every((l) => l.startsWith(' ') && l.trim().length > 0)).toBe(true)
|
|
1781
|
+
})
|
|
1782
|
+
|
|
1783
|
+
it('孤儿(cleanedUp):仍标 [已清理],不展开 status/agent/task 摘要', async () => {
|
|
1784
|
+
await writeOrphanManifest('sa-ghost-id', {
|
|
1785
|
+
agentName: 'worker',
|
|
1786
|
+
task: 'ghost task',
|
|
1787
|
+
slug: 'ghost-slug',
|
|
1788
|
+
status: 'completed',
|
|
1789
|
+
})
|
|
1790
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1791
|
+
const text = r.content[0].text
|
|
1792
|
+
expect(text).toContain('slug=ghost-slug [已清理]')
|
|
1793
|
+
// 已清理即终局:manifest 富字段不进文本(无深读入口,摘要无消费价值)
|
|
1794
|
+
expect(text).not.toContain('ghost task')
|
|
1795
|
+
expect(text).not.toContain('[completed]')
|
|
1796
|
+
})
|
|
1797
|
+
|
|
1798
|
+
it('P-fallback(无 manifest):identity 回退 task/agent 仍展示,status 无则无标签', async () => {
|
|
1799
|
+
await writeSubagent(SUB, { identityTask: 'fix bug', identityAgent: 'worker' })
|
|
1800
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
|
|
1801
|
+
const text = r.content[0].text
|
|
1802
|
+
// P-fallback:task/agent 从 identity 回退(slug 取 identity.slug),status 不可回退 → 无 [x] 标签
|
|
1803
|
+
expect(text).toContain('slug=identity-slug worker · fix bug')
|
|
1804
|
+
expect(text).not.toContain('[completed]')
|
|
1805
|
+
expect(text).not.toContain('[running]')
|
|
1806
|
+
})
|
|
1807
|
+
})
|
|
1808
|
+
|
|
1576
1809
|
// ===========================================================================
|
|
1577
1810
|
// doctor action(u8:design 2026-09-10 §6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)
|
|
1578
1811
|
// 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
|
|
@@ -1581,7 +1814,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1581
1814
|
// doctor details 契约直用 doctor.ts 导出的 DoctorDetails(不再自建局部镜像——生产侧
|
|
1582
1815
|
// 形状漂移由编译期捕捉,S-R2 同步)
|
|
1583
1816
|
|
|
1584
|
-
describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob
|
|
1817
|
+
describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob)', () => {
|
|
1585
1818
|
let tmp: string
|
|
1586
1819
|
|
|
1587
1820
|
const SLUG = '--Users-foo--'
|
|
@@ -1593,8 +1826,8 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1593
1826
|
}
|
|
1594
1827
|
|
|
1595
1828
|
/**
|
|
1596
|
-
*
|
|
1597
|
-
* PI_CODING_AGENT_DIR 形态判据 `<*>/.
|
|
1829
|
+
* taiji 形态四根 fixture:dataDir 目录名带 .taiji 前缀(detectEnvironment 的
|
|
1830
|
+
* PI_CODING_AGENT_DIR 形态判据 `<*>/.taiji*\/agent` 可命中,托管判定走正态)。
|
|
1598
1831
|
* live=agent/sessions 与 default 同字面路径(default 去重)、legacy 独立非空、subagent 独立。
|
|
1599
1832
|
*/
|
|
1600
1833
|
async function buildFourRootFixture(): Promise<{
|
|
@@ -1602,7 +1835,7 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1602
1835
|
agentDir: string
|
|
1603
1836
|
signals: SessionReadSignals
|
|
1604
1837
|
}> {
|
|
1605
|
-
const dataDir = join(tmp, '.
|
|
1838
|
+
const dataDir = join(tmp, '.taiji-fixture')
|
|
1606
1839
|
const agentDir = join(dataDir, 'agent')
|
|
1607
1840
|
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'), 'aaaaaaaa')
|
|
1608
1841
|
await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
|
|
@@ -1615,9 +1848,9 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1615
1848
|
agentDir,
|
|
1616
1849
|
liveSessionDir: join(agentDir, 'sessions'),
|
|
1617
1850
|
env: {
|
|
1618
|
-
|
|
1851
|
+
TAIJI_AGENT_EXT_LOG: '1',
|
|
1619
1852
|
PI_CODING_AGENT_DIR: agentDir,
|
|
1620
|
-
|
|
1853
|
+
TAIJI_AGENT_DATA_DIR: dataDir,
|
|
1621
1854
|
},
|
|
1622
1855
|
},
|
|
1623
1856
|
}
|
|
@@ -1635,8 +1868,8 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1635
1868
|
const r = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1636
1869
|
const text = r.content[0].text
|
|
1637
1870
|
|
|
1638
|
-
// 环境判定行:双信号合取 →
|
|
1639
|
-
expect(text).toContain('环境判定:
|
|
1871
|
+
// 环境判定行:双信号合取 → taiji(托管)+ 数据目录 + evidence 正态
|
|
1872
|
+
expect(text).toContain('环境判定:taiji(托管)')
|
|
1640
1873
|
expect(text).toContain(`数据目录:${dataDir}`)
|
|
1641
1874
|
expect(text).toContain('依据:')
|
|
1642
1875
|
expect(text).toContain(`PI_CODING_AGENT_DIR='${agentDir}' → 形态匹配`)
|
|
@@ -1679,7 +1912,7 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1679
1912
|
const { signals } = await buildFourRootFixture()
|
|
1680
1913
|
const positive = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1681
1914
|
expect(positive.content[0].text).toContain(
|
|
1682
|
-
`告警:[legacy] 根 ${join(tmp, '.
|
|
1915
|
+
`告警:[legacy] 根 ${join(tmp, '.taiji-fixture', 'sessions')} 非空(1 文件)`,
|
|
1683
1916
|
)
|
|
1684
1917
|
|
|
1685
1918
|
// 反态:legacy 目录不存在
|
|
@@ -1700,43 +1933,6 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1700
1933
|
expect(deduped.content[0].text).toContain('已去重')
|
|
1701
1934
|
})
|
|
1702
1935
|
|
|
1703
|
-
it('独立 glob 残留探测:pi/ 含 agent|sessions 形态列出 + 迁移指引;pi.backup-v2-* 备份列出;基点 = dirname(agentDir)', async () => {
|
|
1704
|
-
const dataDir = join(tmp, 'data')
|
|
1705
|
-
const agentDir = join(dataDir, 'agent')
|
|
1706
|
-
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1707
|
-
// 未迁移旧布局 pi/(含 agent/ 形态)
|
|
1708
|
-
await mkdir(join(dataDir, 'pi', 'agent'), { recursive: true })
|
|
1709
|
-
await writeJsonl(join(dataDir, 'pi', 'agent', 'sessions', 'old.jsonl'), 'old-id')
|
|
1710
|
-
// 迁移备份(含 sessions/ 形态——备份即原 pi/ 改名)
|
|
1711
|
-
const backup = join(dataDir, 'pi.backup-v2-1725900000000')
|
|
1712
|
-
await mkdir(join(backup, 'sessions'), { recursive: true })
|
|
1713
|
-
await writeJsonl(join(backup, 'sessions', 'bak.jsonl'), 'bak-id')
|
|
1714
|
-
|
|
1715
|
-
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1716
|
-
const text = r.content[0].text
|
|
1717
|
-
expect(text).toContain(`旧布局残留探测(基点 ${dataDir})`)
|
|
1718
|
-
expect(text).toContain(` - ${join(dataDir, 'pi')}(未迁移旧布局 pi/)`)
|
|
1719
|
-
expect(text).toContain(` - ${backup}(迁移备份 pi.backup-v2-*)`)
|
|
1720
|
-
// 同一迁移指引(§6.11:doctor 与 u14b 启动探测同指引)
|
|
1721
|
-
expect(text).toContain('👉 关闭应用后运行 scripts/migrate-pi-layout-v2.mjs 完成迁移。')
|
|
1722
|
-
|
|
1723
|
-
const d = r.details as DoctorDetails
|
|
1724
|
-
expect(d.globBase).toBe(dataDir)
|
|
1725
|
-
expect(d.leftovers.map((l) => l.kind).sort()).toEqual(['backup', 'unmigrated'])
|
|
1726
|
-
})
|
|
1727
|
-
|
|
1728
|
-
it('残留探测反态:pi/ 空壳(无 agent|sessions 子目录)不报——防纯 pi 宿主 ~/.pi/pi/ 误报(§6.11 v9.1)', async () => {
|
|
1729
|
-
const dataDir = join(tmp, 'data')
|
|
1730
|
-
const agentDir = join(dataDir, 'agent')
|
|
1731
|
-
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1732
|
-
// 纯 pi 宿主下任意来源的 pi/ 目录:只有无关子目录,无 agent/ 与 sessions/ 形态
|
|
1733
|
-
await mkdir(join(dataDir, 'pi', 'extensions'), { recursive: true })
|
|
1734
|
-
|
|
1735
|
-
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1736
|
-
expect(r.content[0].text).not.toContain('旧布局残留')
|
|
1737
|
-
expect((r.details as DoctorDetails).leftovers).toEqual([])
|
|
1738
|
-
})
|
|
1739
|
-
|
|
1740
1936
|
it('subagent 根默认不扫(只列路径与可扫性);includeSubagents:true 才扫出文件数(§6.3)', async () => {
|
|
1741
1937
|
const { agentDir, signals } = await buildFourRootFixture()
|
|
1742
1938
|
|
|
@@ -1753,59 +1949,31 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1753
1949
|
expect(fullSub?.exists).toBe(true)
|
|
1754
1950
|
})
|
|
1755
1951
|
|
|
1756
|
-
it('
|
|
1952
|
+
it('无缓存形态(ext-simplify-04 U3):重复调用输出一致(每次实扫),新落盘文件立即可见', async () => {
|
|
1953
|
+
// 原三个用例(缓存命中不重扫 / TTL 到期失效 / find 不读 doctor 缓存 PS-14)随
|
|
1954
|
+
// doctor 缓存机删除而合并为本用例——无缓存层后「find/doctor 读到旧计数」的形态
|
|
1955
|
+
// 在结构上不存在,守卫点收敛为:输出无「缓存命中」行 + 每次调用实扫(新增文件
|
|
1956
|
+
// 下一次调用即见,反而更实时)
|
|
1757
1957
|
const agentDir = join(tmp, 'agent')
|
|
1758
1958
|
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1759
1959
|
const signals: SessionReadSignals = { agentDir }
|
|
1760
1960
|
|
|
1761
1961
|
const first = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
|
|
1762
1962
|
const firstDef = first.roots.find((x) => x.kind === 'default')!
|
|
1763
|
-
expect(firstDef.cached).toBeUndefined() // 首扫
|
|
1764
1963
|
expect(firstDef.fileCount).toBe(1)
|
|
1765
1964
|
|
|
1766
|
-
const second =
|
|
1767
|
-
const secondDef = second.roots.find((x) => x.kind === 'default')!
|
|
1768
|
-
expect(secondDef.
|
|
1769
|
-
expect(
|
|
1965
|
+
const second = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1966
|
+
const secondDef = (second.details as DoctorDetails).roots.find((x) => x.kind === 'default')!
|
|
1967
|
+
expect(secondDef.fileCount).toBe(1) // 两次输出一致(同目录无变化)
|
|
1968
|
+
expect(second.content[0].text).not.toContain('缓存命中')
|
|
1770
1969
|
|
|
1771
|
-
//
|
|
1772
|
-
await writeJsonl(join(agentDir, 'sessions', 'b.jsonl'), 'bbbbbbbb')
|
|
1970
|
+
// 新 session 落盘 → 下一次 doctor / find 均立即可见(无缓存层,每次实扫)
|
|
1971
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
|
|
1773
1972
|
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)
|
|
1973
|
+
expect(third.roots.find((x) => x.kind === 'default')!.fileCount).toBe(2)
|
|
1974
|
+
const r = await handleSessionRead({ action: 'find', query: 'bbbbbbbb' }, { agentDir })
|
|
1807
1975
|
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
1808
|
-
expect(d.matches.some((m) => m.sessionId.includes('
|
|
1976
|
+
expect(d.matches.some((m) => m.sessionId.includes('bbbbbbbb'))).toBe(true)
|
|
1809
1977
|
})
|
|
1810
1978
|
|
|
1811
1979
|
it('env/bundleUrl 信号缺失降级:仍出环境判定行(standalone-pi · 未知)+ 完整根表,不抛错', async () => {
|
|
@@ -1815,7 +1983,7 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1815
1983
|
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1816
1984
|
const text = r.content[0].text
|
|
1817
1985
|
expect(text).toContain('环境判定:standalone-pi · 发行形态:未知(不猜)')
|
|
1818
|
-
expect(text).toContain('
|
|
1986
|
+
expect(text).toContain('TAIJI_AGENT_EXT_LOG=<未设置> → 未命中')
|
|
1819
1987
|
expect(text).toContain('会话根(按优先级)')
|
|
1820
1988
|
expect(text).toContain('[default]')
|
|
1821
1989
|
expect(text).toContain(
|
|
@@ -1823,13 +1991,12 @@ describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob +
|
|
|
1823
1991
|
)
|
|
1824
1992
|
})
|
|
1825
1993
|
|
|
1826
|
-
it('空 agentDir
|
|
1994
|
+
it('空 agentDir 防御:无根表、诊断「无候选根」(不扫 cwd 相对路径)', async () => {
|
|
1827
1995
|
const r = await handleSessionRead({ action: 'doctor' }, { agentDir: '' })
|
|
1828
1996
|
const text = r.content[0].text
|
|
1829
1997
|
expect(text).toContain('诊断:无候选根')
|
|
1830
1998
|
expect(text).not.toContain('[default]')
|
|
1831
1999
|
expect((r.details as DoctorDetails).roots).toEqual([])
|
|
1832
|
-
expect((r.details as DoctorDetails).leftovers).toEqual([])
|
|
1833
2000
|
})
|
|
1834
2001
|
})
|
|
1835
2002
|
|
|
@@ -1871,7 +2038,7 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
|
|
|
1871
2038
|
|
|
1872
2039
|
/** find action 便捷调用(agentDir = tmp/agent)。 */
|
|
1873
2040
|
function find(query: string, extra?: Partial<SessionReadParams>): Promise<ToolResultLike> {
|
|
1874
|
-
return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
|
|
2041
|
+
return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
|
|
1875
2042
|
}
|
|
1876
2043
|
|
|
1877
2044
|
interface ToolResultLike {
|
|
@@ -1985,9 +2152,10 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
|
|
|
1985
2152
|
expect((hexJunk.details as { matches: unknown[] }).matches).toHaveLength(0)
|
|
1986
2153
|
})
|
|
1987
2154
|
|
|
1988
|
-
it('⑥ 自检行计数 =
|
|
2155
|
+
it('⑥ 自检行计数 = 本次实扫结果 + 去重根注记', async () => {
|
|
1989
2156
|
const agentDir = join(tmp, 'agent')
|
|
1990
|
-
//
|
|
2157
|
+
// 根扫描无缓存(doctor 缓存机已删,ext-simplify-04 U3)——doctor 首跑后落盘的
|
|
2158
|
+
// 文件,find 自检行照实扫计数(原 PS-14「doctor 缓存 0 文件」形态结构性不存在)
|
|
1991
2159
|
await mkdir(join(agentDir, 'sessions'), { recursive: true })
|
|
1992
2160
|
await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1993
2161
|
// 之后 session 落盘(main 2 + subagent 1)
|
|
@@ -2000,7 +2168,6 @@ describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)',
|
|
|
2000
2168
|
{ agentDir },
|
|
2001
2169
|
)
|
|
2002
2170
|
const text = r.content[0].text
|
|
2003
|
-
// 若 F1 读 doctor 缓存(0 文件),以下计数断言必红
|
|
2004
2171
|
expect(text).toContain(`${join(agentDir, 'sessions')}:2 文件`)
|
|
2005
2172
|
expect(text).toContain(`${join(agentDir, 'subagents')}:1 文件`)
|
|
2006
2173
|
expect(text).toContain('候选集非空(共 3 文件)')
|
|
@@ -2072,7 +2239,7 @@ describe('片段解析接通 [live] 根(fixture,同一 roots 契约)', ()
|
|
|
2072
2239
|
// 负向对照:不传 liveSessionDir(三根降级)→ 同一片段 F1 零匹配
|
|
2073
2240
|
//([default]/[legacy]/[subagent] 均无此文件——证明命中确实来自 [live] 根)
|
|
2074
2241
|
await expect(
|
|
2075
|
-
handleSessionRead({ action: 'outline', session: FRAGMENT }, agentDir),
|
|
2242
|
+
handleSessionRead({ action: 'outline', session: FRAGMENT }, { agentDir }),
|
|
2076
2243
|
).rejects.toThrow(/无匹配 session/)
|
|
2077
2244
|
})
|
|
2078
2245
|
|
|
@@ -2176,7 +2343,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2176
2343
|
query: string,
|
|
2177
2344
|
extra?: Partial<SessionReadParams>,
|
|
2178
2345
|
): ReturnType<typeof handleSessionRead> {
|
|
2179
|
-
return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
|
|
2346
|
+
return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
|
|
2180
2347
|
}
|
|
2181
2348
|
|
|
2182
2349
|
interface FindDetails {
|
|
@@ -2304,7 +2471,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2304
2471
|
|
|
2305
2472
|
const res = await handleSessionRead(
|
|
2306
2473
|
{ action: 'result', session: 'sa-u10-r1,sa-u10-r2' },
|
|
2307
|
-
join(tmp, 'agent'),
|
|
2474
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2308
2475
|
)
|
|
2309
2476
|
const text = res.content[0].text
|
|
2310
2477
|
expect(text).toContain(`(session ${R1.slice(0, 8)}…)`)
|
|
@@ -2331,7 +2498,7 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2331
2498
|
|
|
2332
2499
|
// 解析路径(resolveByFragment → findSessions limit:10 无 source)保持 mtime 排序 +
|
|
2333
2500
|
// limit 截断原语义:5 命中全进 F2 消歧候选,无 main 置顶/配额分组介入(§6.7 末条)
|
|
2334
|
-
const r = await handleSessionRead({ action: 'outline', session: PREFIX }, join(tmp, 'agent'))
|
|
2501
|
+
const r = await handleSessionRead({ action: 'outline', session: PREFIX }, { agentDir: join(tmp, 'agent') })
|
|
2335
2502
|
const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
|
|
2336
2503
|
expect(d.ambiguous).toBe(true)
|
|
2337
2504
|
expect(d.candidates).toHaveLength(5)
|
|
@@ -2355,15 +2522,113 @@ describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归
|
|
|
2355
2522
|
})
|
|
2356
2523
|
})
|
|
2357
2524
|
|
|
2525
|
+
// ============================================================
|
|
2526
|
+
// E1:find 单次根解析(ext-simplify-04 U2,探针 P1)。spy 打在 roots 模块命名空间
|
|
2527
|
+
// 上跨模块拦截(find.ts / findNoMatch 渲染链的消费点全走同一入口),断言 doFind
|
|
2528
|
+
// 全路径(分组 / 显式 source / 零匹配)resolveSessionRoots 恰调用 1 次——改前分组
|
|
2529
|
+
// 2 次、零匹配 3 次(impl-plan D-15④ 登记的分组双倍实扫债),预解析根复用后恒 1 次。
|
|
2530
|
+
// ============================================================
|
|
2531
|
+
describe('E1 find 单次根解析(探针 P1:resolveSessionRoots 恰 1 次)', () => {
|
|
2532
|
+
let tmp: string
|
|
2533
|
+
const SLUG = '--e1-cwd--'
|
|
2534
|
+
const PREFIX = '019e7e10'
|
|
2535
|
+
const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
|
|
2536
|
+
const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
|
|
2537
|
+
|
|
2538
|
+
/** 写 session 文件(subagent:true 写入 subagent 根),形态同 u10 段 writeSession。 */
|
|
2539
|
+
async function writeSession(id: string, opts?: { subagent?: boolean }): Promise<void> {
|
|
2540
|
+
const dir = opts?.subagent
|
|
2541
|
+
? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
|
|
2542
|
+
: join(tmp, 'agent', 'sessions', SLUG)
|
|
2543
|
+
await mkdir(dir, { recursive: true })
|
|
2544
|
+
await writeFile(
|
|
2545
|
+
join(dir, `${id}.jsonl`),
|
|
2546
|
+
JSON.stringify({ type: 'session', id, cwd: '/e1' }) + '\n',
|
|
2547
|
+
)
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function find(
|
|
2551
|
+
query: string,
|
|
2552
|
+
extra?: Partial<SessionReadParams>,
|
|
2553
|
+
): ReturnType<typeof handleSessionRead> {
|
|
2554
|
+
return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/** spy 计数器 + 断言 helper:包装一段 find 调用,返回 { 结果, 调用次数 }。 */
|
|
2558
|
+
async function findCounting(
|
|
2559
|
+
query: string,
|
|
2560
|
+
extra?: Partial<SessionReadParams>,
|
|
2561
|
+
): Promise<{ result: Awaited<ReturnType<typeof handleSessionRead>>; resolveCalls: number }> {
|
|
2562
|
+
const rootsModule = await import('../discovery/roots.js')
|
|
2563
|
+
const spy = vi.spyOn(rootsModule, 'resolveSessionRoots')
|
|
2564
|
+
try {
|
|
2565
|
+
const result = await find(query, extra)
|
|
2566
|
+
return { result, resolveCalls: spy.mock.calls.length }
|
|
2567
|
+
} finally {
|
|
2568
|
+
spy.mockRestore()
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
beforeEach(async () => {
|
|
2573
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-e1-'))
|
|
2574
|
+
})
|
|
2575
|
+
afterEach(async () => {
|
|
2576
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
2577
|
+
})
|
|
2578
|
+
|
|
2579
|
+
it('① 分组路径(main + subagent 两段查询,有命中):恰 1 次', async () => {
|
|
2580
|
+
await writeSession(mainId(1))
|
|
2581
|
+
await writeSession(subId(1), { subagent: true })
|
|
2582
|
+
const { result, resolveCalls } = await findCounting(PREFIX)
|
|
2583
|
+
const d = result.details as { matches: Array<{ source: string }>; truncated: boolean }
|
|
2584
|
+
// 前置:两段确实各自命中(分组语义不变),再断言扫描收敛
|
|
2585
|
+
expect(d.matches).toHaveLength(2)
|
|
2586
|
+
expect(new Set(d.matches.map((m) => m.source))).toEqual(new Set(['main', 'subagent']))
|
|
2587
|
+
expect(d.truncated).toBe(false)
|
|
2588
|
+
expect(resolveCalls).toBe(1)
|
|
2589
|
+
})
|
|
2590
|
+
|
|
2591
|
+
it('② 显式 source 路径(单组查询,有命中):恰 1 次', async () => {
|
|
2592
|
+
await writeSession(mainId(1))
|
|
2593
|
+
await writeSession(subId(1), { subagent: true })
|
|
2594
|
+
const { result, resolveCalls } = await findCounting(PREFIX, { source: 'subagent' })
|
|
2595
|
+
const d = result.details as { matches: Array<{ source: string }> }
|
|
2596
|
+
expect(d.matches).toHaveLength(1)
|
|
2597
|
+
expect(d.matches[0].source).toBe('subagent')
|
|
2598
|
+
expect(resolveCalls).toBe(1)
|
|
2599
|
+
})
|
|
2600
|
+
|
|
2601
|
+
it('③ 分组零匹配 → F1 自检行:恰 1 次(改前 3 次)且根表仍进渲染', async () => {
|
|
2602
|
+
await writeSession(mainId(1))
|
|
2603
|
+
const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x')
|
|
2604
|
+
const d = result.details as { matches: unknown[]; truncated: boolean }
|
|
2605
|
+
expect(d.matches).toEqual([])
|
|
2606
|
+
expect(d.truncated).toBe(false)
|
|
2607
|
+
// F1 自检行消费预解析根表(非空根计数可见 = roots 确实传入而非空列表)
|
|
2608
|
+
expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
|
|
2609
|
+
expect(result.content[0].text).toContain('候选集非空')
|
|
2610
|
+
expect(resolveCalls).toBe(1)
|
|
2611
|
+
})
|
|
2612
|
+
|
|
2613
|
+
it('④ 显式 source 零匹配 → F1 自检行:恰 1 次(改前 2 次)', async () => {
|
|
2614
|
+
await writeSession(subId(1), { subagent: true })
|
|
2615
|
+
const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x', { source: 'main' })
|
|
2616
|
+
expect(result.content[0].text).toContain('无匹配 session')
|
|
2617
|
+
expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
|
|
2618
|
+
expect(resolveCalls).toBe(1)
|
|
2619
|
+
})
|
|
2620
|
+
})
|
|
2621
|
+
|
|
2358
2622
|
// ============================================================
|
|
2359
2623
|
// u11:metadataProvider 注入 + TTL 缓存(design 2026-09-10 §6.6)
|
|
2360
|
-
// handler 级集成:注入包装(
|
|
2624
|
+
// handler 级集成:注入包装(metadata 缓存独立实例——根扫描无缓存,doctor 缓存机
|
|
2625
|
+
// 已删除,ext-simplify-04 U3,不存在串扰面)
|
|
2361
2626
|
// + 标题渲染(§5.1 候选行标题优先)+ provider 抛错降级不外抛。
|
|
2362
2627
|
// 策略①惰性/②窄化/合并语义在 find.test.ts u11 段覆盖。
|
|
2363
2628
|
// ============================================================
|
|
2364
2629
|
describe('u11 metadataProvider 注入 + TTL 缓存(fixture,§6.6)', () => {
|
|
2365
2630
|
let tmp: string
|
|
2366
|
-
/** 平铺 main 根(
|
|
2631
|
+
/** 平铺 main 根(taiji 形态),同时作 liveSessionDir(与 [default] 根同字面路径) */
|
|
2367
2632
|
let flatMain: string
|
|
2368
2633
|
|
|
2369
2634
|
async function writeSession(
|
|
@@ -2567,7 +2832,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2567
2832
|
const ids = Array.from({ length: MULTI_SEARCH_MAX_SESSIONS + 1 }, (_, i) => mainId(i + 10))
|
|
2568
2833
|
const r = await handleSessionRead(
|
|
2569
2834
|
{ action: 'search', session: ids.join(','), pattern: 'adj_factor' },
|
|
2570
|
-
join(tmp, 'agent'),
|
|
2835
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2571
2836
|
)
|
|
2572
2837
|
const text = r.content[0].text
|
|
2573
2838
|
expect(text).toContain('跨会话检索已拒绝')
|
|
@@ -2697,7 +2962,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2697
2962
|
await writeSession(A, [{ role: 'user', text: '普通内容' }])
|
|
2698
2963
|
const r = await handleSessionRead(
|
|
2699
2964
|
{ action: 'search', session: `${A},${GHOST}`, pattern: 'zzznothing' },
|
|
2700
|
-
join(tmp, 'agent'),
|
|
2965
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2701
2966
|
)
|
|
2702
2967
|
const text = r.content[0].text
|
|
2703
2968
|
expect(text).toContain('0/1 session(s) hit for /zzznothing/')
|
|
@@ -2721,7 +2986,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2721
2986
|
// 单 id:原 details.hits 结构与 header 形态不变
|
|
2722
2987
|
const r = await handleSessionRead(
|
|
2723
2988
|
{ action: 'search', session: A, pattern: 'plugin' },
|
|
2724
|
-
join(tmp, 'agent'),
|
|
2989
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2725
2990
|
)
|
|
2726
2991
|
expect(r.content[0].text).toContain('1 hit(s) for /plugin/')
|
|
2727
2992
|
const d = r.details as { hits: unknown[]; truncated: boolean; scanned?: unknown }
|
|
@@ -2731,7 +2996,7 @@ describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
|
2731
2996
|
// pattern 含逗号不触发跨会话(分流只看 session)
|
|
2732
2997
|
const r2 = await handleSessionRead(
|
|
2733
2998
|
{ action: 'search', session: A, pattern: 'aaa,bbb' },
|
|
2734
|
-
join(tmp, 'agent'),
|
|
2999
|
+
{ agentDir: join(tmp, 'agent') },
|
|
2735
3000
|
)
|
|
2736
3001
|
expect((r2.details as { hits: unknown[] }).hits).toHaveLength(0)
|
|
2737
3002
|
expect((r2.details as { scanned?: unknown }).scanned).toBeUndefined()
|