@zhushanwen/pi-session-reader 0.13.1 → 0.13.2

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.
@@ -1,3347 +1,1003 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { existsSync } from 'node:fs'
3
- import { mkdtemp, mkdir, writeFile, rm, utimes, readFile } from 'node:fs/promises'
4
- import { tmpdir, homedir } from 'node:os'
1
+ // tool-handler.test.ts —— session-reader C07 簇合并产物(round5-G)。
2
+ // 共同被测业务模块:`src/tool-handler.ts`。mock 相容:全簇零 vi.mock。
3
+ // 顶层正文按来源包 describe 纯前缀分节,it 层名零改动。
4
+ //
5
+ // 来源:
6
+ // - src/__tests__/summary.test.ts(20 用例,skip 0)
7
+ // - src/__tests__/zcode-routing.test.ts(33 用例,skip 0)
8
+
9
+ import { type Theme } from '@earendil-works/pi-coding-agent'
10
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
11
+ import { type SessionReadParams, type ToolResult, handleSessionRead } from '../tool-handler.js'
12
+ import { buildSessionReadSummary, withResultSummary } from '../summary.js'
13
+ import { renderSessionReadResult } from '../tui/tool-render.js'
14
+ import { tmpdir } from 'node:os'
15
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
5
16
  import { join, dirname } from 'node:path'
6
- import {
7
- handleSessionRead,
8
- METADATA_CACHE_TTL_MS,
9
- type SessionReadParams,
10
- type SessionReadSignals,
11
- } from '../tool-handler.js'
12
- import {
13
- resolveSessionStoreRoot,
14
- resolveStoreSubagentSessionsDir,
15
- resolveStoreSubagentRecordsDir,
16
- } from '../discovery/roots.js'
17
+ import { DatabaseSync } from 'node:sqlite'
18
+ import { Check } from 'typebox/value'
19
+ import { SQLITE_DRIVER_UNSUPPORTED_MARK, ZCODE_DB_MISSING_MARK, zcodeReadErrorMessage } from '../tool-format.js'
20
+ import { SqliteUnreadableError } from '@zhushanwen/zcode-session-source'
21
+ import sessionReaderExtension from '../index.js'
17
22
  import { setPiHandle } from '@zhushanwen/pi-extension-logger'
18
- import { type DoctorDetails } from '../doctor.js'
19
- import { levenshtein } from '../no-match.js'
20
- import {
21
- MULTI_SEARCH_MAX_SESSIONS,
22
- SEARCH_SCAN_BYTE_BUDGET,
23
- searchAcrossSessions,
24
- } from '../search-across.js'
25
- import { renderExtractItems } from '../extract.js'
26
- import type { SessionMetadataProvider } from '../discovery/find.js'
27
- import { FIX_E6 } from './fixtures.js'
28
-
29
- // fs-guard:TC6 的「~ 前缀展开」语义需要 homedir,但真实 homedir 根在白名单外(禁止向用户
30
- // 主目录散落文件)。vi.mock 把 homedir 指到用例内 mkdtemp 的 tmp;fakeHome 未注入时回落真实值。
31
- const homeState = vi.hoisted(() => ({ fakeHome: undefined as string | undefined }))
32
- vi.mock('node:os', async (importOriginal) => {
33
- const actual = await importOriginal<typeof import('node:os')>()
34
- return { ...actual, homedir: () => homeState.fakeHome ?? actual.homedir() }
35
- })
36
-
37
- /**
38
- * M3 tool-handler 集成测试。
39
- *
40
- * 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
41
- * agentDir 全部指向 mkdtemp 自建自删的 fixture 目录(按断言逻辑构造的最小 session 文件,
42
- * 不读本机真实用户数据目录),fixture 生命周期与各 describe 的 beforeEach/afterEach 绑定。
43
- *
44
- * 覆盖全 11 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价,
45
- * 以及 u8 doctor / u9 归一化 / u10 find 分组 / u11 metadataProvider 缓存 / u12 跨会话检索(各见对应 describe)。
46
- */
47
23
 
48
- describe('handleSessionRead', () => {
49
- let dataDir: string
50
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
51
- const SLUG = '--demo-cwd--'
52
- // 家族 fixture 组:根 FAM → fork 子代 FORK(跨 cwd,parentSession 指 FAM 文件)
53
- // → 隔代 subagent SUB2(rootSessionId=FORK)。前缀互异,断言按 id 精确区分层级。
54
- const FAM = '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7'
55
- const FORK = '019fe632-aaaa-7bbb-8ccc-00000000fe32'
56
- const SUB2 = '019fe635-aaaa-7bbb-8ccc-00000000fe35'
24
+ // ======== 来源:summary.test.ts ========
25
+ describe("summary", () => {
26
+ // ---- fixtures ----
57
27
 
58
- /**
59
- * 主 fixture session(FIX_E6):2 turn 的最小多形态 session——
60
- * - T000 含 toolCall + toolResult(detail 摘要态断言),文本含 'plugin'(search 断言)
61
- * - T001 纯文本收尾(outline 计数 / fragment 等价断言)
62
- * 另加 1 条无关 session(id 不含 e6c96):find 片段全扫「多条候选恰一条命中」语义。
63
- */
64
- async function writeMainFixtures(): Promise<void> {
65
- const sessionDir = join(dir, 'sessions', SLUG)
66
- await mkdir(sessionDir, { recursive: true })
67
- const msg = (id: string, parentId: string, message: Record<string, unknown>): string =>
68
- JSON.stringify({ type: 'message', id, parentId, message })
69
- const e6Lines = [
70
- JSON.stringify({ type: 'session', id: FIX_E6, cwd: '/demo' }),
71
- msg(`${FIX_E6}-m0`, FIX_E6, {
72
- role: 'user',
73
- content: [{ type: 'text', text: '调研 plugin 架构怎么落地' }],
74
- }),
75
- msg(`${FIX_E6}-m1`, `${FIX_E6}-m0`, {
76
- role: 'assistant',
77
- content: [
78
- { type: 'text', text: '先看现有目录结构' },
79
- { type: 'toolCall', id: 'tc-e6-1', name: 'bash', arguments: { command: 'ls src' } },
80
- ],
81
- }),
82
- msg(`${FIX_E6}-m2`, `${FIX_E6}-m1`, {
83
- role: 'toolResult',
84
- toolName: 'bash',
85
- toolCallId: 'tc-e6-1',
86
- content: [{ type: 'text', text: 'src/plugin.ts' }],
87
- }),
88
- msg(`${FIX_E6}-m3`, `${FIX_E6}-m2`, {
89
- role: 'user',
90
- content: [{ type: 'text', text: '继续深入' }],
91
- }),
92
- msg(`${FIX_E6}-m4`, `${FIX_E6}-m3`, {
93
- role: 'assistant',
94
- content: [{ type: 'text', text: '完成,共两轮。' }],
95
- }),
96
- ]
97
- await writeFile(join(sessionDir, `${FIX_E6}.jsonl`), e6Lines.join('\n') + '\n')
98
- await makeFixtureSession(dir, '019ffff-aaaa-bbbb-cccc-00000000ffff', '无关会话')
28
+ function params(overrides: Partial<SessionReadParams> & { action: SessionReadParams['action'] }): SessionReadParams {
29
+ return overrides as SessionReadParams
99
30
  }
100
31
 
101
- /** 家族 fixture:FAM 根 + FORK(跨 cwd fork)+ 挂在 FORK 下的隔代 subagent(manifest 建族)。 */
102
- async function writeFamilyFixtures(): Promise<void> {
103
- const famPath = join(dir, 'sessions', SLUG, `${FAM}.jsonl`)
104
- await writeFile(famPath, JSON.stringify({ type: 'session', id: FAM, cwd: '/demo' }) + '\n')
105
- const forkDir = join(dir, 'sessions', '--fork-cwd--')
106
- await mkdir(forkDir, { recursive: true })
107
- await writeFile(
108
- join(forkDir, `${FORK}.jsonl`),
109
- JSON.stringify({ type: 'session', id: FORK, cwd: '/fork', parentSession: famPath }) + '\n',
110
- )
111
- await makeFixtureSubagent(dir, 'sa-fe635', { realSessionId: SUB2, rootSessionId: FORK })
32
+ function toolResult(details: unknown, text = 'body text'): ToolResult {
33
+ return { content: [{ type: 'text', text }], details }
112
34
  }
113
35
 
114
- beforeEach(async () => {
115
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-main-'))
116
- dir = join(dataDir, 'agent')
117
- await writeMainFixtures()
118
- })
119
- afterEach(async () => {
120
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
121
- })
36
+ // ---- buildSessionReadSummary ----
122
37
 
123
- it('1. find by uuid fragment returns matching session', async () => {
124
- const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir: dir })
125
- const d = r.details as { matches: Array<{ sessionId: string }> }
126
- expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
127
- expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
128
- })
129
-
130
- it('2. outline yields all turns within token budget (v2 O1: <=1500)', async () => {
131
- const r = await handleSessionRead({ action: 'outline', session: FIX_E6 }, { agentDir: dir })
132
- const d = r.details as { turns: unknown[]; tokenEstimate: number }
133
- expect(d.turns.length).toBe(2)
134
- // v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 1500(design §3.3 D4)
135
- expect(d.tokenEstimate).toBeLessThanOrEqual(1500)
136
- })
137
-
138
- it('3. detail single turn returns toolResult summary by default (v2 O3)', async () => {
139
- // fixture 的 T000 是含 toolCall + toolResult 的 turn
140
- const r = await handleSessionRead({ action: 'detail', session: FIX_E6, turns: 'T000' }, { agentDir: dir })
141
- const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
142
- expect(d.entries.length).toBeGreaterThan(0)
143
- // v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
144
- expect(d.entries.some((e) => e.type === 'toolResultSummary')).toBe(true)
145
- // 不再有 role=toolResult 的原文 entry(除非 includeToolResult:true)
146
- expect(d.entries.some((e) => e.message?.role === 'toolResult')).toBe(false)
147
- })
148
-
149
- it('4. family lists fork children and隔代 subagents', async () => {
150
- await writeFamilyFixtures()
151
- const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: dir })
152
- const d = r.details as {
153
- forks: Array<{ sessionId: string }>
154
- subagents: Array<{ sessionId: string }>
155
- }
156
- expect(d.forks.some((f) => f.sessionId === FORK)).toBe(true)
157
- // 隔代:SUB2.rootSessionId=FORK(fork 子代),从家族根 FAM 出发仍能关联
158
- expect(d.subagents.some((s) => s.sessionId === SUB2)).toBe(true)
159
- })
160
-
161
- it('5. search pattern returns hits', async () => {
162
- const r = await handleSessionRead({ action: 'search', session: FIX_E6, pattern: 'plugin' }, { agentDir: dir })
163
- const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
164
- expect(d.hits.length).toBeGreaterThan(0)
165
- expect(typeof d.hits[0].matchSnippet).toBe('string')
166
- })
167
-
168
- it('6. export outline materializes a .md file under the session store tree', async () => {
169
- // export 写目标 = <dataDir>/ext-sessions/<sid>/session-view.md(session-store-layout-
170
- // inversion 域 6):agentDir 参数 = <dataDir>/agent(mkdtemp tmp 子目录,导出树随 tmp
171
- // 整体清理);读侧经 liveSessionDir=[live] 根扫本 describe 的 fixture sessions 目录。
172
- const dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-export-'))
173
- try {
174
- const r = await handleSessionRead(
175
- { action: 'export', session: FIX_E6, format: 'outline' },
176
- { agentDir: join(dataDir, 'agent'), liveSessionDir: join(dir, 'sessions') },
38
+ describe('buildSessionReadSummary', () => {
39
+ it('find → Found N sessions matching "<query>"(截断带后缀)', () => {
40
+ const r = toolResult({ matches: [{}, {}, {}], truncated: false })
41
+ expect(buildSessionReadSummary(params({ action: 'find', query: 'login' }), r)).toBe(
42
+ 'Found 3 sessions matching "login"',
177
43
  )
178
- const d = r.details as { path: string; sizeBytes: number }
179
- expect(d.path).toBe(join(dataDir, 'ext-sessions', FIX_E6, 'session-view.md'))
180
- expect(existsSync(d.path)).toBe(true)
181
- expect(d.sizeBytes).toBeGreaterThan(0)
182
- } finally {
183
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
184
- }
185
- })
186
-
187
- // source:'main' 收窄到 main 侧(显式 source 查询走单组原语义,见 TC12/TC13)
188
- it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
189
- const r = await handleSessionRead(
190
- { action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
191
- { agentDir: dir },
192
- )
193
- const d = r.details as { matches: unknown[]; truncated: boolean }
194
- expect(d.matches).toEqual([])
195
- expect(d.truncated).toBe(false)
196
- // u9 F1 重写(§5.2):事实型自检 + 无归因断言 + 无「recent 看全量」误导
197
- expect(r.content[0].text).toContain('无匹配 session')
198
- expect(r.content[0].text).toContain('自检(发现层,只陈述事实)')
199
- expect(r.content[0].text).not.toContain('真的没有')
200
- expect(r.content[0].text).not.toContain('recent')
201
- })
202
-
203
- it('8. F4 detail turn out of range throws with 越界', async () => {
204
- await expect(
205
- handleSessionRead({ action: 'detail', session: FIX_E6, turns: 'T999' }, { agentDir: dir }),
206
- ).rejects.toThrow(/越界/)
207
- })
208
-
209
- it('9. F5 outline missing session throws naming session', async () => {
210
- await expect(handleSessionRead({ action: 'outline' }, { agentDir: dir })).rejects.toThrow(/session/)
211
- })
212
-
213
- it('10. resolveSessionId fragment equivalent to full id', async () => {
214
- const rFull = await handleSessionRead({ action: 'outline', session: FIX_E6 }, { agentDir: dir })
215
- const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: dir })
216
- const dFull = rFull.details as { turns: unknown[] }
217
- const dFrag = rFrag.details as { turns: unknown[] }
218
- expect(dFrag.turns.length).toBe(dFull.turns.length)
219
- })
220
- })
221
-
222
- describe('extract (v2 O4)', () => {
223
- let dir: string
224
- let dataDir: string
225
- const SLUG = '--demo-cwd--'
226
-
227
- /**
228
- * extract fixture session(FIX_E6):3 user turn 的最小多素材形态——
229
- * - T000:bash git log(commits git-cmd 主路径:def5678a)+ bash ls + read
230
- * (commits commit-context 次路径:abc1234f,hash 邻近含 'feat:')
231
- * - T001:edit(files op 素材)
232
- * - T002:纯文本收尾
233
- * 计数锚:user 3 / toolCall 4(bash 2)/ toolResult 4(bash 2)。
234
- */
235
- async function writeExtractFixture(): Promise<void> {
236
- const sessionDir = join(dir, 'sessions', SLUG)
237
- await mkdir(sessionDir, { recursive: true })
238
- const msg = (id: string, parentId: string, message: Record<string, unknown>): string =>
239
- JSON.stringify({ type: 'message', id, parentId, message })
240
- const lines = [
241
- JSON.stringify({ type: 'session', id: FIX_E6, cwd: '/demo' }),
242
- msg(`${FIX_E6}-m0`, FIX_E6, {
243
- role: 'user',
244
- content: [{ type: 'text', text: '第一条:查一下最近的提交' }],
245
- }),
246
- msg(`${FIX_E6}-m1`, `${FIX_E6}-m0`, {
247
- role: 'assistant',
248
- content: [
249
- { type: 'text', text: '好,先看 git 记录' },
250
- { type: 'toolCall', id: 'tc-git', name: 'bash', arguments: { command: 'git log --oneline -3' } },
251
- ],
252
- }),
253
- msg(`${FIX_E6}-m2`, `${FIX_E6}-m1`, {
254
- role: 'toolResult',
255
- toolName: 'bash',
256
- toolCallId: 'tc-git',
257
- content: [{ type: 'text', text: 'def5678a feat: fix crash' }],
258
- }),
259
- msg(`${FIX_E6}-m3`, `${FIX_E6}-m2`, {
260
- role: 'assistant',
261
- content: [
262
- { type: 'text', text: '再看下文件列表' },
263
- { type: 'toolCall', id: 'tc-ls', name: 'bash', arguments: { command: 'ls src' } },
264
- ],
265
- }),
266
- msg(`${FIX_E6}-m4`, `${FIX_E6}-m3`, {
267
- role: 'toolResult',
268
- toolName: 'bash',
269
- toolCallId: 'tc-ls',
270
- content: [{ type: 'text', text: 'src/index.ts' }],
271
- }),
272
- msg(`${FIX_E6}-m5`, `${FIX_E6}-m4`, {
273
- role: 'assistant',
274
- content: [
275
- { type: 'text', text: '读一下说明' },
276
- { type: 'toolCall', id: 'tc-read', name: 'read', arguments: { path: '/repo/README.md' } },
277
- ],
278
- }),
279
- msg(`${FIX_E6}-m6`, `${FIX_E6}-m5`, {
280
- role: 'toolResult',
281
- toolName: 'read',
282
- toolCallId: 'tc-read',
283
- content: [{ type: 'text', text: 'deploy abc1234f done\nfeat: add login' }],
284
- }),
285
- msg(`${FIX_E6}-m7`, `${FIX_E6}-m6`, {
286
- role: 'user',
287
- content: [{ type: 'text', text: '第二条:改代码' }],
288
- }),
289
- msg(`${FIX_E6}-m8`, `${FIX_E6}-m7`, {
290
- role: 'assistant',
291
- content: [
292
- { type: 'text', text: '来改' },
293
- {
294
- type: 'toolCall',
295
- id: 'tc-edit',
296
- name: 'edit',
297
- arguments: { path: '/repo/src/app.ts', edits: [{ oldText: 'a', newText: 'b' }] },
298
- },
299
- ],
300
- }),
301
- msg(`${FIX_E6}-m9`, `${FIX_E6}-m8`, {
302
- role: 'toolResult',
303
- toolName: 'edit',
304
- toolCallId: 'tc-edit',
305
- content: [{ type: 'text', text: 'ok' }],
306
- }),
307
- msg(`${FIX_E6}-m10`, `${FIX_E6}-m9`, {
308
- role: 'user',
309
- content: [{ type: 'text', text: '第三条:完成' }],
310
- }),
311
- msg(`${FIX_E6}-m11`, `${FIX_E6}-m10`, {
312
- role: 'assistant',
313
- content: [{ type: 'text', text: '全部完成。' }],
314
- }),
315
- ]
316
- await writeFile(join(sessionDir, `${FIX_E6}.jsonl`), lines.join('\n') + '\n')
317
- }
318
-
319
- beforeEach(async () => {
320
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-extract-'))
321
- dir = join(dataDir, 'agent')
322
- await writeExtractFixture()
323
- })
324
- afterEach(async () => {
325
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
326
- })
327
-
328
- it('user-messages returns all user entries with turn + full text', async () => {
329
- const r = await handleSessionRead(
330
- { action: 'extract', session: FIX_E6, what: 'user-messages' },
331
- { agentDir: dir },
332
- )
333
- const d = r.details as {
334
- what: string
335
- count: number
336
- shown: number
337
- truncated: boolean
338
- items: Array<{ turn: number; text: string }>
339
- }
340
- expect(d.what).toBe('user-messages')
341
- expect(d.count).toBe(3) // 全量 user entry 计数锚(fixture 3 条 user)
342
- // F9 预算可能截断:items 是 shown 子集,shown <= count
343
- expect(d.shown).toBeLessThanOrEqual(d.count)
344
- expect(d.items.length).toBe(d.shown)
345
- expect(d.items.length).toBeGreaterThan(0)
346
- expect(
347
- d.items.every((it) => typeof it.turn === 'number' && typeof it.text === 'string'),
348
- ).toBe(true)
349
- })
350
-
351
- it('commands (no filter) returns all tool calls with name + summary', async () => {
352
- const r = await handleSessionRead(
353
- { action: 'extract', session: FIX_E6, what: 'commands' },
354
- { agentDir: dir },
355
- )
356
- const d = r.details as {
357
- count: number
358
- shown: number
359
- items: Array<{ name: string; summary: string }>
360
- }
361
- expect(d.count).toBe(4) // 全量 toolCall 计数锚(fixture:bash×2 + read + edit)
362
- expect(d.shown).toBeLessThanOrEqual(d.count)
363
- expect(d.items.length).toBe(d.shown)
364
- expect(
365
- d.items.every((it) => typeof it.name === 'string' && typeof it.summary === 'string'),
366
- ).toBe(true)
367
- // 抽查 bash summary 含 "bash: "(D1 映射)
368
- const bashItem = d.items.find((it) => it.name === 'bash')
369
- if (bashItem !== undefined) {
370
- expect(bashItem.summary).toContain('bash: ')
371
- }
372
- })
373
-
374
- it('commands tool=bash returns 2 all bash', async () => {
375
- const r = await handleSessionRead(
376
- { action: 'extract', session: FIX_E6, what: 'commands', tool: 'bash' },
377
- { agentDir: dir },
378
- )
379
- const d = r.details as {
380
- count: number
381
- shown: number
382
- items: Array<{ name: string }>
383
- }
384
- expect(d.count).toBe(2) // bash toolCall 全量(F9 可能截断 shown 子集)
385
- expect(d.items.every((it) => it.name === 'bash')).toBe(true)
386
- })
387
-
388
- it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
389
- const r = await handleSessionRead(
390
- { action: 'extract', session: FIX_E6, what: 'commands', tool: 'nonexist' },
391
- { agentDir: dir },
392
- )
393
- const d = r.details as {
394
- toolDistribution: Array<{ name: string; count: number }>
395
- }
396
- expect(r.content[0].text).toContain('无匹配')
397
- expect(r.content[0].text).toContain('bash×2')
398
- expect(r.content[0].text).toContain('👉')
399
- expect(d.toolDistribution.some((t) => t.name === 'bash' && t.count === 2)).toBe(true)
400
- })
401
-
402
- it('files returns deduped paths with op (read/edit/write/head)', async () => {
403
- const r = await handleSessionRead(
404
- { action: 'extract', session: FIX_E6, what: 'files' },
405
- { agentDir: dir },
406
- )
407
- const d = r.details as {
408
- count: number
409
- items: Array<{ path: string; basename: string; op: string; turns: number[] }>
410
- }
411
- expect(d.count).toBeGreaterThan(0)
412
- expect(d.items.length).toBeGreaterThan(0)
413
- // 抽查含 .ts 或 .md 文件
414
- expect(
415
- d.items.some((it) => it.path.endsWith('.ts') || it.path.endsWith('.md')),
416
- ).toBe(true)
417
- // op 含 read/edit/write/head 之一
418
- expect(d.items.some((it) => /read|edit|write|head/.test(it.op))).toBe(true)
419
- // turns 是数组(去重后出现过的轮次)
420
- expect(d.items.every((it) => Array.isArray(it.turns))).toBe(true)
421
- })
422
-
423
- it('commits returns hash list with 7-8 hex + source turn', async () => {
424
- const r = await handleSessionRead(
425
- { action: 'extract', session: FIX_E6, what: 'commits' },
426
- { agentDir: dir },
427
- )
428
- const d = r.details as {
429
- count: number
430
- items: Array<{ hash: string; turn: number; source: string; context: string }>
431
- }
432
- // git-cmd 主路径(bash git log 结果)取到 hash
433
- expect(d.count).toBeGreaterThan(0)
434
- // commits 已知会误匹配/漏报(D6),只验每条格式
435
- for (const it of d.items) {
436
- expect(it.hash).toMatch(/^[0-9a-f]{7,8}$/)
437
- expect(typeof it.turn).toBe('number')
438
- expect(['git-cmd', 'commit-context']).toContain(it.source)
439
- }
440
- })
441
-
442
- it('tool-results (no filter) returns all results with toolName + text', async () => {
443
- const r = await handleSessionRead(
444
- { action: 'extract', session: FIX_E6, what: 'tool-results' },
445
- { agentDir: dir },
446
- )
447
- const d = r.details as {
448
- count: number
449
- shown: number
450
- items: Array<{ toolName: string; text: string }>
451
- }
452
- expect(d.count).toBe(4) // 全量 toolResult 计数锚(fixture 4 条)
453
- expect(d.shown).toBeLessThanOrEqual(d.count)
454
- expect(
455
- d.items.every((it) => typeof it.toolName === 'string' && typeof it.text === 'string'),
456
- ).toBe(true)
457
- })
458
-
459
- it('tool-results tool=bash returns 2 all bash', async () => {
460
- const r = await handleSessionRead(
461
- { action: 'extract', session: FIX_E6, what: 'tool-results', tool: 'bash' },
462
- { agentDir: dir },
463
- )
464
- const d = r.details as { count: number; items: Array<{ toolName: string }> }
465
- expect(d.count).toBe(2)
466
- expect(d.items.every((it) => it.toolName === 'bash')).toBe(true)
467
- })
468
-
469
- it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
470
- const r = await handleSessionRead(
471
- { action: 'extract', session: FIX_E6, what: 'tool-results', tool: 'nonexist' },
472
- { agentDir: dir },
473
- )
474
- expect(r.content[0].text).toContain('无匹配')
475
- expect(r.content[0].text).toContain('bash×2')
476
- })
477
-
478
- it('F7 missing what throws with 无效 + valid values', async () => {
479
- // what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
480
- // isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
481
- const params: SessionReadParams = { action: 'extract', session: FIX_E6 }
482
- await expect(handleSessionRead(params, { agentDir: dir })).rejects.toThrow(/无效/)
483
- await expect(handleSessionRead(params, { agentDir: dir })).rejects.toThrow(
484
- /user-messages\/commands\/files\/commits\/tool-results/,
485
- )
486
- })
487
-
488
- it('S2: extract turns 越界文案说明 extract turn 体系(不指向 outline)', async () => {
489
- // extract 用全量 entry 分段(含 compaction/旁支),turn index 与 outline leaf 视图不对齐
490
- let msg = ''
491
- try {
492
- await handleSessionRead(
493
- { action: 'extract', session: FIX_E6, what: 'user-messages', turns: 'T999' },
494
- { agentDir: dir },
44
+ const truncated = toolResult({ matches: [{}], truncated: true })
45
+ expect(buildSessionReadSummary(params({ action: 'find', query: 'login' }), truncated)).toBe(
46
+ 'Found 1 sessions matching "login" (truncated)',
495
47
  )
496
- } catch (e) {
497
- msg = (e as Error).message
498
- }
499
- expect(msg).toContain('extract 的 turn 范围与 outline 不同')
500
- expect(msg).not.toContain('用 outline 重看有效范围')
501
- expect(msg).toContain('该 session extract 共')
502
- })
503
- })
504
-
505
- // ---- fixture 工具(tmpdir 造最小 session 文件,供 F2/MF-5 用例)----
506
-
507
- async function makeFixtureSession(
508
- dir: string,
509
- id: string,
510
- firstUserText: string,
511
- ): Promise<void> {
512
- const slug = '--demo-cwd--'
513
- await mkdir(join(dir, 'sessions', slug), { recursive: true })
514
- const lines = [
515
- JSON.stringify({ type: 'session', id, cwd: '/demo' }),
516
- JSON.stringify({
517
- type: 'message',
518
- id: id + '-m1',
519
- parentId: id,
520
- message: { role: 'user', content: [{ type: 'text', text: firstUserText }] },
521
- }),
522
- ]
523
- await writeFile(join(dir, 'sessions', slug, id + '.jsonl'), lines.join('\n') + '\n')
524
- }
525
-
526
- describe('F2 多匹配消歧(fixture,MF-9)', () => {
527
- let dataDir: string
528
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,[session-store-layout-inversion])
529
-
530
- beforeEach(async () => {
531
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-f2-'))
532
- dir = join(dataDir, 'agent')
533
- })
534
- afterEach(async () => {
535
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
536
- })
537
-
538
- it('共享 uuid 片段 → 不抛错,content 含两候选 + 👉,details.ambiguous=true', async () => {
539
- const ID1 = '019e6c96-aaaa-bbbb-cccc-000000000001'
540
- const ID2 = '019e6c96-aaaa-bbbb-cccc-000000000002'
541
- await makeFixtureSession(dir, ID1, '第一段内容')
542
- await makeFixtureSession(dir, ID2, '第二段内容')
543
-
544
- // outline 走 resolveSessionId → 2 匹配 → F2 消歧(不抛错)
545
- const r = await handleSessionRead({ action: 'outline', session: '019e6c96-aaaa' }, { agentDir: dir })
546
- const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
547
- expect(d.ambiguous).toBe(true)
548
- expect(d.candidates).toHaveLength(2)
549
- expect(d.candidates.some((c) => c.sessionId === ID1)).toBe(true)
550
- expect(d.candidates.some((c) => c.sessionId === ID2)).toBe(true)
551
- const text = r.content[0].text
552
- expect(text).toContain(ID1)
553
- expect(text).toContain(ID2)
554
- expect(text).toContain('👉')
555
- })
556
-
557
- it('search/detail/expand/extract 同样走 F2 消歧(不抛错)', async () => {
558
- const ID1 = '019e6c96-aaaa-bbbb-cccc-000000000001'
559
- const ID2 = '019e6c96-aaaa-bbbb-cccc-000000000002'
560
- await makeFixtureSession(dir, ID1, '第一段内容')
561
- await makeFixtureSession(dir, ID2, '第二段内容')
562
-
563
- for (const action of ['detail', 'expand', 'search', 'export', 'extract'] as const) {
564
- const params: SessionReadParams = { action, session: '019e6c96-aaaa' }
565
- if (action === 'detail') params.turns = 'T001'
566
- if (action === 'expand') params.turn = 'T001'
567
- if (action === 'search') params.pattern = 'x'
568
- if (action === 'extract') params.what = 'user-messages'
569
- const r = await handleSessionRead(params, { agentDir: dir })
570
- const d = r.details as { ambiguous: boolean }
571
- expect(d.ambiguous, `action=${action}`).toBe(true)
572
- expect(r.content[0].text).toContain('👉')
573
- }
574
- })
575
- })
576
-
577
- describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)', () => {
578
- let dir: string
579
- let dataDir: string
580
- const SID = '019e6c96-bbbb-cccc-dddd-00000000000b'
581
-
582
- beforeEach(async () => {
583
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-skipped-'))
584
- dir = join(dataDir, 'agent')
585
- })
586
- afterEach(async () => {
587
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
588
- })
589
-
590
- it('坏行计入 stats.skippedLines 且文本尾部可见(不静默跳过)', async () => {
591
- // 中间注入 1 行坏 JSON(半截对象):parser 计 skippedLines=1,outline 必须报告
592
- const slug = '--demo-cwd--'
593
- await mkdir(join(dir, 'sessions', slug), { recursive: true })
594
- const lines = [
595
- JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
596
- JSON.stringify({
597
- type: 'message',
598
- id: SID + '-m1',
599
- parentId: SID,
600
- message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
601
- }),
602
- '{"broken json line',
603
- ]
604
- await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
605
-
606
- const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
607
- const d = r.details as { stats: { skippedLines: number } }
608
- expect(d.stats.skippedLines).toBe(1)
609
- expect(r.content[0].text).toContain('1 skipped lines')
610
- })
611
-
612
- it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
613
- await makeFixtureSession(dir, SID, 'clean session')
614
- const r = await handleSessionRead({ action: 'outline', session: SID }, { agentDir: dir })
615
- const d = r.details as { stats: { skippedLines: number } }
616
- expect(d.stats.skippedLines).toBe(0)
617
- expect(r.content[0].text).not.toContain('skipped lines')
618
- })
619
- })
620
-
621
- // ============================================================
622
- // E7:行渲染统一(ext-simplify-04 U5,design D3)。outline 与 export {format:"outline"}
623
- // 共用 renderOutline 返回的渲染行(OutlineResult.lines)+ 同一尾段拼装——预算度量 =
624
- // 展示 by construction;旁支标记统一为 `[旁支 N entries]`(改前 handler 侧第二份行
625
- // 格式知识输出裸 `[旁支]`,已随 formatOutlineText 删除)。
626
- // ============================================================
627
- describe('E7 行渲染统一(outline 与 export outline 同一渲染行)', () => {
628
- let dir: string
629
- let dataDir: string
630
- const SID = '019e6c96-bbbb-cccc-dddd-00000000000e'
631
-
632
- /** 写含 fork 旁支的 session:主链 R→m1(user)→m2(assistant),b1/b2 挂 m1 下成
633
- * 旁支子树(buildTreeView:branches[m1]=2;segmentTurns:单 turn [m1, m2],
634
- * 旁支 entry 不进 leaf 视图)。 */
635
- async function writeForkSession(): Promise<void> {
636
- await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
637
- const msg = (id: string, parentId: string, role: 'user' | 'assistant', text: string) =>
638
- JSON.stringify({
639
- type: 'message',
640
- id: `${SID}-${id}`,
641
- parentId,
642
- message: { role, content: [{ type: 'text', text }] },
643
- })
644
- const lines = [
645
- JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
646
- msg('m1', SID, 'user', 'fork point turn'),
647
- msg('b1', `${SID}-m1`, 'assistant', 'branch one'),
648
- msg('b2', `${SID}-m1`, 'assistant', 'branch two'),
649
- msg('m2', `${SID}-m1`, 'assistant', 'main line answer'),
650
- ]
651
- await writeFile(join(dir, 'sessions', '--demo-cwd--', `${SID}.jsonl`), lines.join('\n') + '\n')
652
- }
653
-
654
- beforeEach(async () => {
655
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-e7-'))
656
- dir = join(dataDir, 'agent')
657
- })
658
- afterEach(async () => {
659
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
660
- })
661
-
662
- it('① allBranches:true → 旁支行标记 `[旁支 N entries]`(N=forkPoint 旁支子树 entry 数)+ 尾段同改前语义', async () => {
663
- await writeForkSession()
664
- const r = await handleSessionRead(
665
- { action: 'outline', session: SID, allBranches: true },
666
- { agentDir: dir },
667
- )
668
- const text = r.content[0].text
669
- // 旁支标记带 entry 数;裸 `[旁支]` 旧形态零出现('[旁支]' 与 '[旁支 2 entries]' 互不为子串)
670
- expect(text).toContain('T000 · fork point turn · → main line answer · [旁支 2 entries]')
671
- expect(text).not.toContain('[旁支]')
672
- // stats 尾段与改前语义一致(totalEntries = leaf 2 + 旁支 2)
673
- expect(text).toContain('1 turns · 4 entries · ')
674
- })
675
-
676
- it('② 两 action 一致性:export {format:"outline"} 物化全文 === outline content 文本(旁支行同形态)', async () => {
677
- await writeForkSession()
678
- const outline = await handleSessionRead(
679
- { action: 'outline', session: SID, allBranches: true },
680
- { agentDir: dir },
681
- )
682
- const exp = await handleSessionRead(
683
- { action: 'export', session: SID, format: 'outline', allBranches: true },
684
- { agentDir: dir },
685
- )
686
- const d = exp.details as { path: string }
687
- const exported = await readFile(d.path, 'utf8')
688
- // 干净 session(无坏行)下两 action 全文逐字节一致——同一 result.lines + 同一尾段拼装
689
- expect(exported).toBe(outline.content[0].text)
690
- expect(exported).toContain('[旁支 2 entries]')
691
- })
692
- })
693
-
694
- describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
695
- let dir: string
696
- let dataDir: string
697
- const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
698
-
699
- beforeEach(async () => {
700
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-search-'))
701
- dir = join(dataDir, 'agent')
702
- await makeFixtureSession(dir, SID, 'aaa plugin 内容')
703
- })
704
- afterEach(async () => {
705
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
706
- })
707
-
708
- it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
709
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
710
- const d = r.details as { hits: unknown[] }
711
- // 字面量 '(a+)+' 不在内容里 → 0 命中(若按正则执行会命中 'aaa' 且可能指数回溯)
712
- expect(d.hits).toHaveLength(0)
713
- })
714
-
715
- it('普通正则仍按正则匹配', async () => {
716
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: 'a+' }, { agentDir: dir })
717
- const d = r.details as { hits: unknown[] }
718
- expect(d.hits.length).toBeGreaterThan(0)
719
- })
720
-
721
- it('范围量词 {m,n} 形态 → 降级字面子串((a{1,3})*、(a{0,2})*、(a{1,3}){2,},MF-1 回归)', async () => {
722
- for (const pattern of ['(a{1,3})*', '(a{0,2})*', '(a{1,3}){2,}']) {
723
- const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
724
- const d = r.details as { hits: unknown[] }
725
- // 字面量不含这些 pattern → 0 命中;若按正则执行会指数回溯挂死
726
- expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
727
- }
728
- })
729
-
730
- it('alternation/嵌套量词分支 (a|aa)+、(a*)* → 降级字面子串(MF-5 分支回归)', async () => {
731
- for (const pattern of ['(a|aa)+', '(a*)*']) {
732
- const r = await handleSessionRead({ action: 'search', session: SID, pattern }, { agentDir: dir })
733
- const d = r.details as { hits: unknown[] }
734
- expect(d.hits, `pattern=${pattern}`).toHaveLength(0)
735
- }
736
- })
737
-
738
- it('(a{1,3}) 单独使用不被降级(组后无尾随量词,仍按正则执行)', async () => {
739
- const r = await handleSessionRead(
740
- { action: 'search', session: SID, pattern: '(a{1,3})' },
741
- { agentDir: dir },
742
- )
743
- const d = r.details as { hits: unknown[] }
744
- // 按正则执行命中 'aaa' → >0 命中(若被降级为字面量则 0 命中)
745
- expect(d.hits.length).toBeGreaterThan(0)
746
- })
747
-
748
- it('降级标注:header 含「已降级为字面子串匹配」(S-3)', async () => {
749
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '(a+)+' }, { agentDir: dir })
750
- expect(r.content[0].text).toContain('已降级为字面子串匹配')
751
- })
752
-
753
- it('非法正则 pattern → 字面子串兜底不抛错(零命中,S-6)', async () => {
754
- const r = await handleSessionRead({ action: 'search', session: SID, pattern: '[unclosed' }, { agentDir: dir })
755
- const d = r.details as { hits: unknown[] }
756
- expect(d.hits).toHaveLength(0)
757
- })
758
-
759
- it('scope 过滤:scope=assistant 零命中(fixture 仅 user 角色,S-6)', async () => {
760
- const r = await handleSessionRead(
761
- { action: 'search', session: SID, pattern: 'aaa', scope: 'assistant' },
762
- { agentDir: dir },
763
- )
764
- const d = r.details as { hits: unknown[] }
765
- expect(d.hits).toHaveLength(0)
766
- })
767
-
768
- it('limit 截断 → truncated=true + hits 限长(3 条命中 limit=2,S-6)', async () => {
769
- const sid2 = '019e6c96-bbbb-cccc-dddd-00000000000b'
770
- // message 链式 parentId(与真实 pi session 一致):m0→session 根,m1→m0,m2→m1,
771
- // 否则 buildTreeView 只回溯最后一条的父链,前两条被当旁支过滤
772
- const lines = [
773
- JSON.stringify({ type: 'session', id: sid2, cwd: '/demo' }),
774
- ...['aa1', 'aa2', 'aa3'].map((t, i) =>
775
- JSON.stringify({
776
- type: 'message',
777
- id: `${sid2}-m${i}`,
778
- parentId: i === 0 ? sid2 : `${sid2}-m${i - 1}`,
779
- message: { role: 'user', content: [{ type: 'text', text: t }] },
780
- }),
781
- ),
782
- ]
783
- await writeFile(
784
- join(dir, 'sessions', '--demo-cwd--', `${sid2}.jsonl`),
785
- lines.join('\n') + '\n',
786
- )
787
- const r = await handleSessionRead(
788
- { action: 'search', session: sid2, pattern: 'aa', limit: 2 },
789
- { agentDir: dir },
790
- )
791
- const d = r.details as { hits: unknown[]; truncated: boolean }
792
- expect(d.truncated).toBe(true)
793
- expect(d.hits).toHaveLength(2)
794
- })
795
-
796
- it('aborted signal → search 抛中断错误(不继续扫描)', async () => {
797
- const ac = new AbortController()
798
- ac.abort()
799
- await expect(
800
- handleSessionRead({ action: 'search', session: SID, pattern: 'x' }, { agentDir: dir }, ac.signal),
801
- ).rejects.toThrow(/中断/)
802
- })
803
- })
804
-
805
- describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-context 次路径 + 去重)', () => {
806
- let dir: string
807
- let dataDir: string
808
- const SID = '019e6c96-bbbb-cccc-dddd-00000000000c'
809
-
810
- beforeEach(async () => {
811
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-commits-'))
812
- dir = join(dataDir, 'agent')
813
- })
814
- afterEach(async () => {
815
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
816
- })
817
-
818
- /** 写单个 session JSONL(commits 用例行内联传入)。 */
819
- async function writeCommitsSession(id: string, lines: string[]): Promise<void> {
820
- await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
821
- await writeFile(
822
- join(dir, 'sessions', '--demo-cwd--', `${id}.jsonl`),
823
- lines.join('\n') + '\n',
824
- )
825
- }
826
-
827
- it('commit-context 次路径:非 git bash 的 toolResult,hash 邻近含关键词才收(D6)', async () => {
828
- await writeCommitsSession(SID, [
829
- JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
830
- JSON.stringify({
831
- type: 'message',
832
- id: `${SID}-m1`,
833
- parentId: SID,
834
- message: {
835
- role: 'assistant',
836
- content: [
837
- { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: join(tmpdir(), 'log.txt') } },
838
- ],
839
- },
840
- }),
841
- // read 结果:hash 邻近 30 字符窗口含 'feat:' → commit-context 次路径纳入
842
- JSON.stringify({
843
- type: 'message',
844
- id: `${SID}-m2`,
845
- parentId: `${SID}-m1`,
846
- message: {
847
- role: 'toolResult',
848
- toolName: 'read',
849
- toolCallId: 'tc-read-1',
850
- content: [{ type: 'text', text: 'deploy abc1234f done\nfeat: add login' }],
851
- },
852
- }),
853
- // read 结果:hash 无关键词邻近 → 两路径都不收(过滤分支)
854
- JSON.stringify({
855
- type: 'message',
856
- id: `${SID}-m3`,
857
- parentId: `${SID}-m2`,
858
- message: {
859
- role: 'assistant',
860
- content: [
861
- { type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: join(tmpdir(), 'out.txt') } },
862
- ],
863
- },
864
- }),
865
- JSON.stringify({
866
- type: 'message',
867
- id: `${SID}-m4`,
868
- parentId: `${SID}-m3`,
869
- message: {
870
- role: 'toolResult',
871
- toolName: 'read',
872
- toolCallId: 'tc-read-2',
873
- content: [{ type: 'text', text: 'output 9998887 plain text' }],
874
- },
875
- }),
876
- ])
877
- const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
878
- const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
879
- const hashes = d.items.map((it) => it.hash)
880
- expect(hashes).toContain('abc1234f')
881
- expect(hashes).not.toContain('9998887')
882
- expect(d.items.find((it) => it.hash === 'abc1234f')?.source).toBe('commit-context')
883
- })
884
-
885
- it('去重高置信优先:同 hash 同时命中 git-cmd 与 commit-context → 保留 git-cmd 一条', async () => {
886
- await writeCommitsSession(SID, [
887
- JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
888
- JSON.stringify({
889
- type: 'message',
890
- id: `${SID}-m1`,
891
- parentId: SID,
892
- message: {
893
- role: 'assistant',
894
- content: [
895
- { type: 'toolCall', id: 'tc-bash-1', name: 'bash', arguments: { command: 'git log --oneline -3' } },
896
- ],
897
- },
898
- }),
899
- // bash + git log → git-cmd 高置信(含 def5678a)
900
- JSON.stringify({
901
- type: 'message',
902
- id: `${SID}-m2`,
903
- parentId: `${SID}-m1`,
904
- message: {
905
- role: 'toolResult',
906
- toolName: 'bash',
907
- toolCallId: 'tc-bash-1',
908
- content: [{ type: 'text', text: 'def5678a feat: fix crash' }],
909
- },
910
- }),
911
- JSON.stringify({
912
- type: 'message',
913
- id: `${SID}-m3`,
914
- parentId: `${SID}-m2`,
915
- message: {
916
- role: 'assistant',
917
- content: [
918
- { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: join(tmpdir(), 'deploy.txt') } },
919
- ],
920
- },
921
- }),
922
- // read 结果同 hash def5678a + 关键词 merged → low 候选,去重后丢弃
923
- JSON.stringify({
924
- type: 'message',
925
- id: `${SID}-m4`,
926
- parentId: `${SID}-m3`,
927
- message: {
928
- role: 'toolResult',
929
- toolName: 'read',
930
- toolCallId: 'tc-read-1',
931
- content: [{ type: 'text', text: 'deploy def5678a merged' }],
932
- },
933
- }),
934
- ])
935
- const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, { agentDir: dir })
936
- const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
937
- const dupes = d.items.filter((it) => it.hash === 'def5678a')
938
- expect(dupes).toHaveLength(1)
939
- expect(dupes[0].source).toBe('git-cmd')
940
- expect(d.count).toBe(1)
941
- })
942
- })
943
-
944
- describe('renderExtractItems F9 截断(S3 首项超大 + S4 文案)', () => {
945
- it('S3: 首项超大内部截断 → body 不超预算,shown<count + truncated=true', () => {
946
- // 首项 10000 字符 >> EXTRACT_BUDGET_BYTES(8000);旧逻辑放行首项致 body≈10KB
947
- const longText = 'x'.repeat(10000)
948
- const items = [
949
- { turn: 5, text: longText },
950
- { turn: 6, text: 'short6' },
951
- { turn: 7, text: 'short7' },
952
- ]
953
- const r = renderExtractItems(
954
- 'user-messages',
955
- items,
956
- (it) => `T${String(it.turn).padStart(3, '0')}: ${it.text}`,
957
- (it) => [it.turn],
958
- )
959
- const d = r.details as { what: string; count: number; shown: number; truncated: boolean }
960
- expect(d.truncated).toBe(true)
961
- expect(d.count).toBe(3)
962
- expect(d.shown).toBe(1) // 首项截断后保留,后续项因预算用未加入
963
- // body(含文案行)字节数远小于 3 项全量(30000+ 字节),≤ 预算 + 合理余量
964
- const bodyBytes = Buffer.byteLength(r.content[0].text, 'utf8')
965
- expect(bodyBytes).toBeLessThan(8000 * 1.5)
966
- // 截断标记存在(首项被内部 slice + 省略号)
967
- expect(r.content[0].text).toContain('…')
968
- // 完整 longText 不在输出里(已被截断)
969
- expect(r.content[0].text).not.toContain(longText)
970
- })
971
-
972
- it('S4: F9 文案含实际 turn 范围 + 实际 token(非固定 2000)', () => {
973
- // 3 项各 ~3000 字节,累计超 8000 → 第 3 项触发截断(文案报 shown turn 范围)
974
- const items = [
975
- { turn: 10, text: 'a'.repeat(3000) },
976
- { turn: 20, text: 'b'.repeat(3000) },
977
- { turn: 30, text: 'c'.repeat(3000) },
978
- ]
979
- const r = renderExtractItems(
980
- 'user-messages',
981
- items,
982
- (it) => `T${String(it.turn).padStart(3, '0')}: ${it.text}`,
983
- (it) => [it.turn],
984
- )
985
- const text = r.content[0].text
986
- // 文案含 turn 范围(shown 的 min-max turn)
987
- expect(text).toMatch(/T010-T0\d{2}/)
988
- // 文案含实际 token(从 body 实际字节算),不再是固定 2000
989
- expect(text).toContain('token 达预算上限')
990
- expect(text).not.toContain('≈2000 token')
991
- // 文案含 shown/count
992
- expect(text).toMatch(/已显示 \d+\/\d+ 项/)
993
- })
994
- })
995
-
996
- // ============================================================
997
- // w2 新增:resolveSessionId 三形态(① 绝对路径 / ② sa-id / ③ findSessions 透传 source)
998
- // + ES1/ES2 错误契约 + source 透传 + 真实数据检查(design TC2-TC18)
999
- // ============================================================
1000
-
1001
- /**
1002
- * 造 subagent manifest + session 文件(TC7/TC8/TC10/CQ3/TC14-TC17 用)。
1003
- * sessionFileExists:false 模拟 GC(manifest 存在但 session 文件不存在 → ES1)。
1004
- * slug 可指定 cwd 编码目录名(TC15 worktree 形态),缺省 '--demo-cwd--'。
1005
- * 返回 session 文件绝对路径(= manifest.sessionFile)。
1006
- */
1007
- async function makeFixtureSubagent(
1008
- dir: string,
1009
- saId: string,
1010
- opts: {
1011
- realSessionId: string
1012
- rootSessionId?: string
1013
- agentName?: string
1014
- sessionFileExists?: boolean
1015
- firstUserText?: string
1016
- slug?: string
1017
- },
1018
- ): Promise<string> {
1019
- // [session-store-layout-inversion] 新布局存储树:分区键 = rootSessionId(无 encodeCwd
1020
- // 目录层,opts.slug 已无布局语义,保留入参兼容旧调用点但不参与路径推导)
1021
- const rootSid = opts.rootSessionId ?? 'root-session-1'
1022
- const sessionFile = join(resolveStoreSubagentSessionsDir(dir, rootSid), `${opts.realSessionId}.jsonl`)
1023
- if (opts.sessionFileExists !== false) {
1024
- await mkdir(resolveStoreSubagentSessionsDir(dir, rootSid), { recursive: true })
1025
- const lines = [
1026
- JSON.stringify({ type: 'session', id: opts.realSessionId, cwd: '/demo' }),
1027
- JSON.stringify({
1028
- type: 'message',
1029
- id: opts.realSessionId + '-m1',
1030
- parentId: opts.realSessionId,
1031
- message: {
1032
- role: 'user',
1033
- content: [{ type: 'text', text: opts.firstUserText ?? 'subagent work' }],
1034
- },
1035
- }),
1036
- ]
1037
- await writeFile(sessionFile, lines.join('\n') + '\n')
1038
- }
1039
- const recordsDir = resolveStoreSubagentRecordsDir(dir, rootSid)
1040
- await mkdir(recordsDir, { recursive: true })
1041
- await writeFile(
1042
- join(recordsDir, `${saId}.json`),
1043
- JSON.stringify({
1044
- id: saId,
1045
- rootSessionId: rootSid,
1046
- agentName: opts.agentName ?? 'explorer',
1047
- sessionFile,
1048
- }),
1049
- )
1050
- return sessionFile
1051
- }
1052
-
1053
- describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
1054
- let dataDir: string
1055
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,[session-store-layout-inversion])
1056
-
1057
- beforeEach(async () => {
1058
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-path-'))
1059
- dir = join(dataDir, 'agent')
1060
- })
1061
- afterEach(async () => {
1062
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1063
- })
1064
-
1065
- it('TC2: 绝对路径 outline → sessionId=header 真实 id(非文件名)', async () => {
1066
- const fileId = '019e6c96-dddd-eeee-ffff-000000000b1'
1067
- // 文件名 arbitrary-name 与 header id 不同,验 sessionId 取 header id
1068
- const filePath = join(dir, 'sessions', '--demo-cwd--', 'arbitrary-name.jsonl')
1069
- await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
1070
- const lines = [
1071
- JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }),
1072
- JSON.stringify({
1073
- type: 'message',
1074
- id: fileId + '-m1',
1075
- parentId: fileId,
1076
- message: { role: 'user', content: [{ type: 'text', text: 'hi' }] },
1077
- }),
1078
- ]
1079
- await writeFile(filePath, lines.join('\n') + '\n')
1080
- // export 的 details.path = session-view-<sessionId>.md,含 header 真实 id,不含文件名
1081
- const r = await handleSessionRead(
1082
- { action: 'export', session: filePath, format: 'outline' },
1083
- { agentDir: dir },
1084
- )
1085
- const d = r.details as { path: string }
1086
- expect(d.path).toContain(fileId)
1087
- expect(d.path).not.toContain('arbitrary-name')
1088
- })
1089
-
1090
- it('TC3: 绝对路径文件不存在 → F6 风格错误(含 👉)', async () => {
1091
- const filePath = join(
1092
- dir,
1093
- 'sessions',
1094
- '--demo-cwd--',
1095
- `not-exist-${Date.now()}.jsonl`,
1096
- )
1097
- await expect(
1098
- handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
1099
- ).rejects.toThrow(/读取失败.*文件不存在/)
1100
- await expect(
1101
- handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
1102
- ).rejects.toThrow('👉')
1103
- })
1104
-
1105
- it('TC4: 绝对路径非 .jsonl → F6 风格错误', async () => {
1106
- const filePath = join(dir, 'sessions', '--demo-cwd--', 'x.txt')
1107
- await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
1108
- await writeFile(filePath, 'not jsonl')
1109
- await expect(
1110
- handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
1111
- ).rejects.toThrow(/读取失败.*非 \.jsonl/)
1112
- })
1113
-
1114
- it('TC5: 绝对路径 header 读不出(首行非 session header)→ F6 风格错误', async () => {
1115
- const filePath = join(dir, 'sessions', '--demo-cwd--', 'bad.jsonl')
1116
- await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
1117
- await writeFile(filePath, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
1118
- await expect(
1119
- handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
1120
- ).rejects.toThrow(/读取失败.*首行非合法 session header/)
1121
- })
1122
-
1123
- it('TC5 变体: 绝对路径空文件 → F6 风格错误', async () => {
1124
- const filePath = join(dir, 'sessions', '--demo-cwd--', 'empty.jsonl')
1125
- await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
1126
- await writeFile(filePath, '')
1127
- await expect(
1128
- handleSessionRead({ action: 'outline', session: filePath }, { agentDir: dir }),
1129
- ).rejects.toThrow(/读取失败.*首行非合法 session header/)
1130
- })
1131
-
1132
- it('TC6: ~ 前缀展开到 homedir(文件实际在 homedir 下)', async () => {
1133
- // fs-guard:真实 homedir 根在白名单外(禁止在用户主目录根散落 .sr-w2-test-*)——
1134
- // homedir 经文件头 vi.mock 指到 mkdtemp tmp(白名单内),「~ 前缀展开」断言语义不变
1135
- const fakeHome = await mkdtemp(join(tmpdir(), 'tool-handler-tc6-home-'))
1136
- homeState.fakeHome = fakeHome
1137
- try {
1138
- const home = homedir()
1139
- const tmpUnderHome = await mkdtemp(join(home, '.sr-w2-test-'))
1140
- try {
1141
- const fileId = '019e6c96-dddd-eeee-ffff-0000000006c1'
1142
- const sessionFile = join(tmpUnderHome, 's.jsonl')
1143
- await writeFile(
1144
- sessionFile,
1145
- JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }) + '\n',
1146
- )
1147
- // ~/开头的相对 homedir 路径
1148
- const tildePath = '~/' + sessionFile.slice(home.length + 1)
1149
- const r = await handleSessionRead(
1150
- { action: 'export', session: tildePath, format: 'outline' },
1151
- { agentDir: dir },
1152
- )
1153
- expect((r.details as { path: string }).path).toContain(fileId)
1154
- } finally {
1155
- await rm(tmpUnderHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1156
- }
1157
- } finally {
1158
- homeState.fakeHome = undefined
1159
- await rm(fakeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1160
- }
1161
- })
1162
- })
1163
-
1164
- describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
1165
- let dataDir: string
1166
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,[session-store-layout-inversion])
1167
-
1168
- beforeEach(async () => {
1169
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-said-'))
1170
- dir = join(dataDir, 'agent')
1171
- })
1172
- afterEach(async () => {
1173
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1174
- })
1175
-
1176
- it('TC7: sa-id 恰 1 命中 + sessionFile 存在 → 成功,sessionId=header 真实 id(非 sa-)', async () => {
1177
- const realId = '019e6c96-dddd-eeee-ffff-0000000007a1'
1178
- await makeFixtureSubagent(dir, 'sa-aaa', { realSessionId: realId, firstUserText: 'do task' })
1179
- const r = await handleSessionRead(
1180
- { action: 'export', session: 'sa-aaa', format: 'outline' },
1181
- { agentDir: dir },
1182
- )
1183
- const d = r.details as { path: string }
1184
- expect(d.path).toContain(realId)
1185
- expect(d.path).not.toContain('sa-aaa')
1186
- })
1187
-
1188
- it('TC8: sa-id 命中但 sessionFile 不存在(GC/未写入)→ ES1(含 manifest 元数据 + 👉)', async () => {
1189
- await makeFixtureSubagent(dir, 'sa-gc', {
1190
- realSessionId: '019e6c96-dddd-eeee-ffff-0000000008a2',
1191
- rootSessionId: 'root-1',
1192
- agentName: 'explorer',
1193
- sessionFileExists: false,
1194
- })
1195
- await expect(
1196
- handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir }),
1197
- ).rejects.toThrow('session 文件不存在')
1198
- // 错误含 manifest 全部元数据 + 👉
1199
- try {
1200
- await handleSessionRead({ action: 'outline', session: 'sa-gc' }, { agentDir: dir })
1201
- } catch (e) {
1202
- const msg = (e as Error).message
1203
- expect(msg).toContain('sa-gc')
1204
- expect(msg).toContain('root-1')
1205
- expect(msg).toContain('explorer')
1206
- expect(msg).toContain('sessionFile:')
1207
- expect(msg).toContain('👉')
1208
- expect(msg).toContain('action:"family"')
1209
- }
1210
- })
1211
-
1212
- it('TC9: sa-id 0 命中(可能 running)→ zcode_record_not_found(U9:sa-id 语境统一错误范围,含 family 指引 + 👉)', async () => {
1213
- await expect(
1214
- handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
1215
- ).rejects.toThrow('zcode_record_not_found')
1216
- try {
1217
- await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
1218
- } catch (e) {
1219
- const msg = (e as Error).message
1220
- expect(msg).toContain('未落盘')
1221
- expect(msg).toContain('action:"family"')
1222
- expect(msg).toContain('👉')
1223
- }
1224
- })
1225
-
1226
- it('TC10: sa-id 片段输入(精确相等不命中)→ zcode_record_not_found + 完整 sa- id 重试指引(U9 统一错误范围)', async () => {
1227
- await makeFixtureSubagent(dir, 'sa-c8c8dfa8', {
1228
- realSessionId: '019e6c96-dddd-eeee-ffff-0000000010a3',
1229
48
  })
1230
- // 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中(兜底亦未命中)
1231
- await expect(
1232
- handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
1233
- ).rejects.toThrow('zcode_record_not_found')
1234
- // 片段输入恢复路径在指引内可见(A4:ES2 原文案「用完整 sa- id 重试」的覆盖补全)
1235
- await expect(
1236
- handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
1237
- ).rejects.toThrow(/完整 sa- id(形如 `sa-xxxx`)后重试/)
1238
- })
1239
49
 
1240
- it('sa-id 多 manifest 命中(数据异常)→ ES2 ambiguous(C4)', async () => {
1241
- // 同 sa-id 的两个 manifest(不同 rootSessionId 分区,模拟数据异常——新布局分区键 =
1242
- // rootSessionId,同分区同名 manifest 会互覆,双 manifest 只能跨分区构造)
1243
- for (const [rootSid, realId] of [
1244
- ['0aaaaaaa-bbbb-7ccc-dddd-0000000004r1', '019e6c96-dddd-eeee-ffff-0000000004a4'],
1245
- ['0aaaaaaa-bbbb-7ccc-dddd-0000000004r2', '019e6c96-dddd-eeee-ffff-0000000004a5'],
1246
- ] as const) {
1247
- const sessionsDir = resolveStoreSubagentSessionsDir(dir, rootSid)
1248
- const sessionFile = join(sessionsDir, `${realId}.jsonl`)
1249
- await mkdir(sessionsDir, { recursive: true })
1250
- await writeFile(
1251
- sessionFile,
1252
- JSON.stringify({ type: 'session', id: realId, cwd: '/demo' }) + '\n',
1253
- )
1254
- const recordsDir = resolveStoreSubagentRecordsDir(dir, rootSid)
1255
- await mkdir(recordsDir, { recursive: true })
1256
- await writeFile(
1257
- join(recordsDir, 'sa-dup.json'),
1258
- JSON.stringify({
1259
- id: 'sa-dup',
1260
- rootSessionId: rootSid,
1261
- agentName: 'a',
1262
- sessionFile,
1263
- }),
50
+ it('find 零匹配 → No sessions matching', () => {
51
+ const r = toolResult({ matches: [], truncated: false })
52
+ expect(buildSessionReadSummary(params({ action: 'find', query: 'gone' }), r)).toBe(
53
+ 'No sessions matching "gone"',
1264
54
  )
1265
- }
1266
- await expect(
1267
- handleSessionRead({ action: 'outline', session: 'sa-dup' }, { agentDir: dir }),
1268
- ).rejects.toThrow(/匹配 2 个 record.*数据异常/)
1269
- })
1270
-
1271
- it('CQ3: sa-id 命中但 sessionFile header 读不出 → F6 风格(不降级 record.id 当 sessionId)', async () => {
1272
- // sessionFile 存在但首行非 session header → readSessionHeaderIdSync 返 undefined
1273
- const rootSid = 'r' // 合法 pi sid 形态(单字符分区)
1274
- const sessionFile = join(resolveStoreSubagentSessionsDir(dir, rootSid), 'bad.jsonl')
1275
- await mkdir(dirname(sessionFile), { recursive: true })
1276
- await writeFile(sessionFile, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
1277
- const recordsDir = resolveStoreSubagentRecordsDir(dir, rootSid)
1278
- await mkdir(recordsDir, { recursive: true })
1279
- await writeFile(
1280
- join(recordsDir, 'sa-bad.json'),
1281
- JSON.stringify({
1282
- id: 'sa-bad',
1283
- rootSessionId: 'r',
1284
- agentName: 'a',
1285
- sessionFile,
1286
- }),
1287
- )
1288
- // 抛 F6 风格(读取失败 + 首行非合法 session header),不降级返回 sa-bad 当 sessionId
1289
- await expect(
1290
- handleSessionRead({ action: 'outline', session: 'sa-bad' }, { agentDir: dir }),
1291
- ).rejects.toThrow(/读取失败.*首行非合法 session header/)
1292
- })
1293
- })
1294
-
1295
- describe('sa-id entry 兜底 v2 终态条锚定(W1 D1 版本门补齐,第三层兜底消费方)', () => {
1296
- // fixture 骨架对齐 zcode-routing.test.ts:root/agent = agentDir、root/live-sessions =
1297
- // liveSessionDir(entry 兜底候选根)、root/engines/zcode/session-db/db.sqlite = 隔离库
1298
- // 白名单形态路径(不落文件——正向用例锚命中后进 zcode 读链,在白名单存在性检查处
1299
- // 产出 zcode_db_unreadable,无需真实 sqlite)。
1300
- const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
1301
- let root: string
1302
- let agentDir: string
1303
- let liveDir: string
1304
- let dbPath: string
1305
- let appendEntries: ReturnType<typeof vi.fn>
1306
-
1307
- beforeEach(async () => {
1308
- root = await mkdtemp(join(tmpdir(), 'tool-handler-v2anchor-'))
1309
- agentDir = join(root, 'agent')
1310
- liveDir = join(root, 'live-sessions')
1311
- await mkdir(agentDir, { recursive: true })
1312
- await mkdir(liveDir, { recursive: true })
1313
- dbPath = join(root, ...ISOLATED_DB_SEGMENTS)
1314
- appendEntries = vi.fn()
1315
- setPiHandle({ appendEntry: appendEntries } as unknown as Parameters<typeof setPiHandle>[0])
1316
- })
1317
- afterEach(async () => {
1318
- setPiHandle(undefined)
1319
- await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1320
- })
1321
-
1322
- /** live session 内追加 subagent-record custom 行(首行 session header)。 */
1323
- async function writeLiveEntryLine(lineData: Record<string, unknown>): Promise<void> {
1324
- const path = join(liveDir, 'main.jsonl')
1325
- await writeFile(
1326
- path,
1327
- JSON.stringify({ type: 'session', id: 'live-main-1', cwd: '/proj' }) +
1328
- '\n' +
1329
- JSON.stringify({ type: 'custom', customType: 'subagent-record', id: 'e-v2', parentId: null, data: lineData }) +
1330
- '\n',
1331
- )
1332
- }
1333
-
1334
- /** v2 终态条 data(载荷形态对齐 subagent-core SubagentRecordSettledEntryData)。 */
1335
- function v2SettledData(opts: { engine?: string; sessionRef?: Record<string, string> } = {}): Record<string, unknown> {
1336
- const data: Record<string, unknown> = {
1337
- v: 2,
1338
- kind: 'settled',
1339
- id: 'sa-v2-1',
1340
- status: 'idle',
1341
- stopReason: 'end_turn',
1342
- endedAt: 123,
1343
- turns: 2,
1344
- totalTokens: 20,
1345
- model: undefined,
1346
- thinkingLevel: undefined,
1347
- result: 'done text',
1348
- }
1349
- if (opts.engine !== undefined) data.engine = opts.engine
1350
- if (opts.sessionRef !== undefined) {
1351
- data.engineHandle = { sessionRef: opts.sessionRef, poolKey: 'shared' }
1352
- }
1353
- return data
1354
- }
1355
-
1356
- /** v2 注册条 data(身份域,不携带 engineHandle——非锚源)。 */
1357
- function v2RegisteredData(): Record<string, unknown> {
1358
- return {
1359
- v: 2,
1360
- kind: 'registered',
1361
- id: 'sa-v2-1',
1362
- agent: 'dev',
1363
- task: 't',
1364
- slug: 's',
1365
- origin: 'tool',
1366
- rootSessionId: 'live-main-1',
1367
- depth: 0,
1368
- startedAt: 1,
1369
- }
1370
- }
1371
-
1372
- function logCallsWithReason(reason: string): number {
1373
- return appendEntries.mock.calls.filter(
1374
- ([ct, d]) => ct === 'session-reader:log' && (d as { data?: { reason?: string } })?.data?.reason === reason,
1375
- ).length
1376
- }
1377
-
1378
- it('v2 终态条完整锚 → 兜底命中进 zcode 读链(dbPath 白名单内未落盘 → zcode_db_unreadable,非 not_found)', async () => {
1379
- await writeLiveEntryLine(v2SettledData({ engine: 'zcode', sessionRef: { sessionId: 'sess_v2', dbPath } }))
1380
- // 锚命中 → resolveZcodeRoute → zcode 读链 → 白名单存在性检查(文件未落盘)
1381
- // zcode_db_unreadable = 锚定成功的输出级证据(锚未命中此处是 zcode_record_not_found)
1382
- await expect(
1383
- handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
1384
- ).rejects.toThrow('zcode_db_unreadable')
1385
- })
1386
-
1387
- it('v2 终态条 engine=zcode 但 sessionRef 缺 dbPath → zcode_anchor_missing + 结构化日志记「缺 dbPath」', async () => {
1388
- await writeLiveEntryLine(v2SettledData({ engine: 'zcode', sessionRef: { sessionId: 'sess_v2' } }))
1389
- await expect(
1390
- handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
1391
- ).rejects.toThrow('zcode_anchor_missing')
1392
- // 归因进结构化日志不进 LLM 可见部分(§3.4):firstIncompleteAnchorReason 的 v2 分支
1393
- //(tool-handler 唯一消费点)产出 missing-dbPath
1394
- expect(logCallsWithReason('missing-dbPath')).toBeGreaterThanOrEqual(1)
1395
- })
1396
-
1397
- it('v2 注册条(kind="registered")不是锚源 → zcode_record_not_found(不误报 anchor_missing)', async () => {
1398
- await writeLiveEntryLine(v2RegisteredData())
1399
- await expect(
1400
- handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
1401
- ).rejects.toThrow('zcode_record_not_found')
1402
- await expect(
1403
- handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
1404
- ).rejects.not.toThrow('zcode_anchor_missing')
1405
- })
1406
-
1407
- it('v2 终态条 engine 缺省(pi 形态 v2)→ zcode_record_not_found(D5 engine 判别防误归因)', async () => {
1408
- // pi record 的 v2 终态条:engine 缺省、sessionRef 无 dbPath——不是「zcode 锚不完整」,
1409
- // 不适用旧版本产物指引;误归因 missing-dbPath → zcode_anchor_missing 即红
1410
- await writeLiveEntryLine(v2SettledData())
1411
- await expect(
1412
- handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
1413
- ).rejects.toThrow('zcode_record_not_found')
1414
- await expect(
1415
- handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
1416
- ).rejects.not.toThrow('zcode_anchor_missing')
1417
- })
1418
- })
1419
-
1420
- describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)', () => {
1421
- let dataDir: string
1422
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,[session-store-layout-inversion])
1423
-
1424
- beforeEach(async () => {
1425
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-src-'))
1426
- dir = join(dataDir, 'agent')
1427
- })
1428
- afterEach(async () => {
1429
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1430
- })
1431
-
1432
- it('TC12: find source 过滤——subagent 只返回 subagent 候选,main 只返回 main', async () => {
1433
- const sharedFragment = '019e6c96'
1434
- const mainId = `${sharedFragment}-aaaa-bbbb-cccc-0000000012d1`
1435
- const subId = `${sharedFragment}-aaaa-bbbb-cccc-0000000012d2`
1436
- await makeFixtureSession(dir, mainId, 'main content')
1437
- await makeFixtureSubagent(dir, 'sa-sub12', { realSessionId: subId, firstUserText: 'sub content' })
1438
-
1439
- // 无 source → 两者
1440
- const rBoth = await handleSessionRead(
1441
- { action: 'find', query: sharedFragment },
1442
- { agentDir: dir },
1443
- )
1444
- const dBoth = rBoth.details as {
1445
- matches: Array<{ source: string; sessionId: string }>
1446
- }
1447
- expect(dBoth.matches.some((m) => m.sessionId === mainId)).toBe(true)
1448
- expect(dBoth.matches.some((m) => m.sessionId === subId)).toBe(true)
1449
-
1450
- // source:subagent → 只 sub
1451
- const rSub = await handleSessionRead(
1452
- { action: 'find', query: sharedFragment, source: 'subagent' },
1453
- { agentDir: dir },
1454
- )
1455
- const dSub = rSub.details as {
1456
- matches: Array<{ source: string; sessionId: string }>
1457
- }
1458
- expect(dSub.matches.every((m) => m.source === 'subagent')).toBe(true)
1459
- expect(dSub.matches.some((m) => m.sessionId === subId)).toBe(true)
1460
- expect(dSub.matches.some((m) => m.sessionId === mainId)).toBe(false)
1461
-
1462
- // source:main → 只 main
1463
- const rMain = await handleSessionRead(
1464
- { action: 'find', query: sharedFragment, source: 'main' },
1465
- { agentDir: dir },
1466
- )
1467
- const dMain = rMain.details as {
1468
- matches: Array<{ source: string; sessionId: string }>
1469
- }
1470
- expect(dMain.matches.every((m) => m.source === 'main')).toBe(true)
1471
- expect(dMain.matches.some((m) => m.sessionId === mainId)).toBe(true)
1472
- expect(dMain.matches.some((m) => m.sessionId === subId)).toBe(false)
1473
- })
1474
-
1475
- it('TC13: outline source:main → resolveSessionId ③ 收窄到 main 候选(无 source 时多匹配 F2)', async () => {
1476
- const sharedFragment = '019e6c96'
1477
- const mainId = `${sharedFragment}-aaaa-bbbb-cccc-0000000013e1`
1478
- const subId = `${sharedFragment}-aaaa-bbbb-cccc-0000000013e2`
1479
- await makeFixtureSession(dir, mainId, 'main content')
1480
- await makeFixtureSubagent(dir, 'sa-sub13', { realSessionId: subId, firstUserText: 'sub content' })
1481
-
1482
- // 无 source → main+sub 共享片段 → 2 匹配 → F2 消歧
1483
- const rMulti = await handleSessionRead(
1484
- { action: 'outline', session: sharedFragment },
1485
- { agentDir: dir },
1486
- )
1487
- expect((rMulti.details as { ambiguous: boolean }).ambiguous).toBe(true)
1488
-
1489
- // source:main → 收窄到 main → 唯一匹配 → outline 成功,且是 main(export path 含 mainId)
1490
- const rExp = await handleSessionRead(
1491
- { action: 'export', session: sharedFragment, source: 'main', format: 'outline' },
1492
- { agentDir: dir },
1493
- )
1494
- expect((rExp.details as { path: string }).path).toContain(mainId)
1495
- })
1496
- })
1497
-
1498
- describe('subagent sa-id(w2 TC14-TC17,fixture)', () => {
1499
- let dataDir: string
1500
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
1501
- const TC14_ID = '0aaaaaaa-0000-7000-8000-0000000014a1'
1502
- const TC15_ID = '0aaaaaaa-0000-7000-8000-0000000015a2'
1503
-
1504
- /**
1505
- * 两个存活 subagent + 一个 GC manifest:
1506
- * - TC14 走普通 slug(sa-id → manifest → sessionFile → outline)
1507
- * - TC15 的 sessionFile 落在 '--private-var-folders-' 编码目录(worktree cwd 形态),
1508
- * 覆盖 manifest 树的递归扫描
1509
- * - TC17 的 manifest sessionFile 指向不存在路径(模拟 .jsonl 被 GC)
1510
- */
1511
- beforeEach(async () => {
1512
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-tc14-'))
1513
- dir = join(dataDir, 'agent')
1514
- await makeFixtureSubagent(dir, 'sa-tc14', {
1515
- realSessionId: TC14_ID,
1516
- firstUserText: 'subagent task',
1517
- })
1518
- await makeFixtureSubagent(dir, 'sa-tc15', {
1519
- realSessionId: TC15_ID,
1520
- slug: '--private-var-folders-abc--',
1521
- firstUserText: 'worktree subagent task',
1522
55
  })
1523
- await makeFixtureSubagent(dir, 'sa-tc17-gc', {
1524
- realSessionId: '0aaaaaaa-0000-7000-8000-0000000017a3',
1525
- sessionFileExists: false,
1526
- })
1527
- })
1528
- afterEach(async () => {
1529
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1530
- })
1531
-
1532
- it('TC14: completed subagent sa-id outline 成功(场景 1,sessionId=header 真实 id)', async () => {
1533
- const r = await handleSessionRead({ action: 'outline', session: 'sa-tc14' }, { agentDir: dir })
1534
- const d = r.details as { turns: unknown[] }
1535
- expect(d.turns.length).toBeGreaterThan(0)
1536
- })
1537
-
1538
- it('TC15: worktree 编码目录 subagent 读取(场景 1b,递归扫描覆盖)', async () => {
1539
- const r = await handleSessionRead({ action: 'outline', session: 'sa-tc15' }, { agentDir: dir })
1540
- expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1541
- })
1542
-
1543
- it('TC16: 嵌套后代直接 outline(场景 1c,绝对路径形态读任意节点)', async () => {
1544
- // 绝对路径形态直接读(M0 入口不依赖 findSessions)
1545
- const r = await handleSessionRead(
1546
- {
1547
- action: 'outline',
1548
- session: join(resolveStoreSubagentSessionsDir(dir, 'root-session-1'), `${TC14_ID}.jsonl`),
1549
- },
1550
- { agentDir: dir },
1551
- )
1552
- expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1553
- })
1554
-
1555
- it('TC17: GC/failed manifest → ES1(场景 4,sessionFile 不存在)', async () => {
1556
- await expect(
1557
- handleSessionRead({ action: 'outline', session: 'sa-tc17-gc' }, { agentDir: dir }),
1558
- ).rejects.toThrow('session 文件不存在')
1559
- })
1560
- })
1561
-
1562
- // ============================================================
1563
- // w6: doWorkflow action(TC-w6-single-run/multi-run/runid-filter/runid-not-found/no-runs/snapshot-skip/call-jump)
1564
- // ============================================================
1565
-
1566
- // ---- workflow fixture 常量(uuid 特征,互不为子串)----
1567
- const WF_ROOT = '019w6aaa-0000-7000-b000-000000000001' // 发起 workflow 的 main session
1568
- const WF_CALL = '019w6bbb-0000-7000-b000-000000000002' // workflow call 的目标 session(call-jump)
1569
- const SUB_WF_ROOT = '019w6ccc-0000-7000-b000-000000000003' // 发起 workflow 的 subagent session(MF-2)
1570
-
1571
- /** 写 main session(header + 1 条 user message,让 outline 有 turn)。返回绝对路径。 */
1572
- async function wfMainSession(
1573
- dir: string,
1574
- slug: string,
1575
- id: string,
1576
- opts?: { cwd?: string },
1577
- ): Promise<string> {
1578
- const sessionDir = join(dir, 'sessions', slug)
1579
- await mkdir(sessionDir, { recursive: true })
1580
- const path = join(sessionDir, `${id}.jsonl`)
1581
- const lines = [
1582
- JSON.stringify({ type: 'session', id, cwd: opts?.cwd ?? `/proj/${slug}` }),
1583
- JSON.stringify({
1584
- type: 'message',
1585
- id: `${id}-m1`,
1586
- parentId: id,
1587
- message: { role: 'user', content: [{ type: 'text', text: 'run workflow' }] },
1588
- }),
1589
- ]
1590
- await writeFile(path, lines.join('\n') + '\n')
1591
- return path
1592
- }
1593
-
1594
- /** 写 wf-state 文件(每行一个快照 JSON)。返回绝对路径。 */
1595
- async function wfStateFile(
1596
- dir: string,
1597
- slug: string,
1598
- fileName: string,
1599
- lines: string[],
1600
- ): Promise<string> {
1601
- const wfDir = join(dir, 'sessions', slug, 'workflow-state')
1602
- await mkdir(wfDir, { recursive: true })
1603
- const path = join(wfDir, fileName)
1604
- await writeFile(path, lines.join('\n') + '\n')
1605
- return path
1606
- }
1607
-
1608
- describe('doWorkflow(w6,fixture)', () => {
1609
- let dataDir: string
1610
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,[session-store-layout-inversion])
1611
-
1612
- beforeEach(async () => {
1613
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-wf-'))
1614
- dir = join(dataDir, 'agent')
1615
- })
1616
- afterEach(async () => {
1617
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1618
- })
1619
-
1620
- // ---- [D16③] v2 record 流档概览(recordPath 锚点 = record 流路径)----
1621
-
1622
- /** 向 main session 追加 workflow-record v2 注册条目行(recordPath 锚点指向 record 流)。 */
1623
- async function wfV2Registered(
1624
- dir: string,
1625
- slug: string,
1626
- id: string,
1627
- runId: string,
1628
- recordPath: string,
1629
- ): Promise<void> {
1630
- const sessionPath = join(dir, 'sessions', slug, `${id}.jsonl`)
1631
- const line = JSON.stringify({
1632
- type: 'custom',
1633
- id: `wf-reg-${runId}`,
1634
- parentId: id,
1635
- customType: 'workflow-record',
1636
- data: { v: 2, kind: 'registered', runId, workflowName: 'rec-flow', scriptName: 'rec-flow', slug: 'rec-flow', startedAt: 1758000000000, recordPath: recordPath },
1637
- timestamp: '2026-09-28T00:00:00Z',
1638
- })
1639
- await writeFile(sessionPath, line + '\n', { flag: 'a' })
1640
- }
1641
-
1642
- /** 构造 v2 record 流文件(run-created + agent-settled[+终局] 帧提 calls)并写注册条目——
1643
- * [ADR-0095] v1 读侧删除后的单档 fixture 形态(替代 wf-state 快照 + link 指针)。 */
1644
- async function wfV2Run(
1645
- dir: string,
1646
- slug: string,
1647
- id: string,
1648
- runId: string,
1649
- calls: Array<{ sessionId: string; sessionFile: string }>,
1650
- opts: { settled?: boolean } = {},
1651
- ): Promise<string> {
1652
- const lines = [
1653
- JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId, workflowName: 'test-wf', argsSummary: '{}' }),
1654
- ...calls.map((c, i) =>
1655
- JSON.stringify({ type: 'agent-settled', seq: 2 + i, ts: 1758000001000 + i, taskIndex: i, attempt: 1, outcome: 'done', durationMs: 100, result: { content: 'ok', sessionId: c.sessionId, sessionFile: c.sessionFile } }),
1656
- ),
1657
- ]
1658
- if (opts.settled) {
1659
- lines.push(JSON.stringify({ type: 'run-settled', seq: 2 + calls.length, ts: 1758000002000, outcome: 'done' }))
1660
- }
1661
- const recordPath = await wfStateFile(dir, slug, `${runId}.record.jsonl`, lines)
1662
- await wfV2Registered(dir, slug, id, runId, recordPath)
1663
- return recordPath
1664
- }
1665
-
1666
- it('[D16③] v2 档 record 流概览:注册条目锚点直读流——steps 提 sessionFile、status 三态、不进 skippedRuns(活跃 run 红线)', async () => {
1667
- const slug = '--wf-record--'
1668
- await wfMainSession(dir, slug, WF_ROOT)
1669
- const callSession = join(dir, 'sessions', slug, `${WF_CALL}.jsonl`)
1670
- const recordPath = await wfStateFile(dir, slug, 'wf-rec-1.record.jsonl', [
1671
- JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-rec-1', workflowName: 'rec-flow', argsSummary: '{}' }),
1672
- JSON.stringify({ type: 'agent-started', seq: 2, ts: 1758000000100, taskIndex: 0, agentName: 'rec-step', attempt: 1 }),
1673
- JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1758000001500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 1400, result: { content: 'ok', sessionId: WF_CALL, sessionFile: callSession } }),
1674
- JSON.stringify({ type: 'run-settled', seq: 4, ts: 1758000002000, outcome: 'done', artifactsDir: '/tmp/wf' }),
1675
- ])
1676
- await wfV2Registered(dir, slug, WF_ROOT, 'wf-rec-1', recordPath)
1677
-
1678
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1679
- const d = r.details as {
1680
- runs: Array<{ runId: string; status: string; script?: string; steps: Array<{ sessionId?: string; sessionFile?: string }> }>
1681
- skippedRuns?: unknown[]
1682
- }
1683
- // 红线:record 流 run 正常解析——不退化为 skippedRuns
1684
- expect(d.skippedRuns).toBeUndefined()
1685
- expect(d.runs).toHaveLength(1)
1686
- expect(d.runs[0].runId).toBe('wf-rec-1')
1687
- expect(d.runs[0].status).toBe('done')
1688
- expect(d.runs[0].script).toBe('rec-flow')
1689
- expect(d.runs[0].steps).toHaveLength(1)
1690
- expect(d.runs[0].steps[0].sessionFile).toBe(callSession)
1691
- expect(d.runs[0].steps[0].sessionId).toBe(WF_CALL)
1692
- expect(r.content[0].type).toBe('text')
1693
- expect((r.content[0] as { text: string }).text).toContain('wf-rec-1')
1694
- expect((r.content[0] as { text: string }).text).toContain('rec-step')
1695
- })
1696
-
1697
- it('[D16③][D2] v2 档活跃 run(流无终局帧)→ status running、概览不退化为 skippedRuns;中断转移帧 → interrupted', async () => {
1698
- const slug = '--wf-running--'
1699
- await wfMainSession(dir, slug, WF_ROOT)
1700
- // 活跃:run-created + agent-started(无 settled/终局帧)
1701
- const activePath = await wfStateFile(dir, slug, 'wf-run-1.record.jsonl', [
1702
- JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-run-1', workflowName: 'run-flow', argsSummary: '{}' }),
1703
- JSON.stringify({ type: 'agent-started', seq: 2, ts: 1758000000100, taskIndex: 0, agentName: 'in-flight', attempt: 1 }),
1704
- ])
1705
- await wfV2Registered(dir, slug, WF_ROOT, 'wf-run-1', activePath)
1706
- const r1 = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1707
- const d1 = r1.details as { runs: Array<{ runId: string; status: string }>; skippedRuns?: unknown[] }
1708
- expect(d1.skippedRuns).toBeUndefined()
1709
- expect(d1.runs.find((x) => x.runId === 'wf-run-1')?.status).toBe('running')
1710
-
1711
- // 中断:追加 run-interrupted 转移帧 → status interrupted([D2] 暂停态)
1712
- // 同 slug 同 main session 追加条目(第二个 slug 目录会触发同 id 多匹配消歧)
1713
- const slug2 = slug
1714
- const intPath = await wfStateFile(dir, slug2, 'wf-int-1.record.jsonl', [
1715
- JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-int-1', workflowName: 'int-flow', argsSummary: '{}' }),
1716
- JSON.stringify({ type: 'agent-started', seq: 2, ts: 1758000000100, taskIndex: 0, agentName: 'crashed-step', attempt: 1 }),
1717
- JSON.stringify({ type: 'run-interrupted', seq: 3, ts: 1758000009000, errorCode: 'crashed', reason: 'process killed' }),
1718
- ])
1719
- await wfV2Registered(dir, slug2, WF_ROOT, 'wf-int-1', intPath)
1720
- const r2 = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1721
- const d2 = r2.details as { runs: Array<{ runId: string; status: string }>; skippedRuns?: unknown[] }
1722
- expect(d2.skippedRuns).toBeUndefined()
1723
- expect(d2.runs.find((x) => x.runId === 'wf-int-1')?.status).toBe('interrupted')
1724
- })
1725
56
 
1726
- it('TC-w6-single-run:单 run 概览,content 含 run 头行/budget/step,details.runs/runIds 非空', async () => {
1727
- const slug = '--wf-single--'
1728
- await wfMainSession(dir, slug, WF_ROOT)
1729
- const callSession = join(dir, 'sessions', slug, `${WF_CALL}.jsonl`)
1730
- await wfV2Run(dir, slug, WF_ROOT, 'wf-single-1', [
1731
- { sessionId: WF_CALL, sessionFile: callSession },
1732
- ], { settled: true })
1733
-
1734
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1735
- const d = r.details as {
1736
- runs: Array<{
1737
- runId: string
1738
- status: string
1739
- steps: Array<{ sessionId: string; sessionFile: string }>
1740
- }>
1741
- runIds: string[]
1742
- }
1743
- expect(d.runs).toHaveLength(1)
1744
- expect(d.runs[0].runId).toBe('wf-single-1')
1745
- expect(d.runs[0].status).toBe('done')
1746
- expect(d.runs[0].steps).toHaveLength(1)
1747
- expect(d.runs[0].steps[0].sessionId).toBe(WF_CALL)
1748
- expect(d.runs[0].steps[0].sessionFile).toBe(callSession)
1749
- expect(d.runIds).toEqual(['wf-single-1'])
1750
- // content 含 renderWorkflowOverview 输出
1751
- const text = r.content[0].text
1752
- expect(text).toContain('run: wf-single-1')
1753
- expect(text).toContain('[done]')
1754
- expect(text).toContain('budget:')
1755
- expect(text).toContain('#0')
1756
- expect(text).toContain('call=' + WF_CALL.slice(0, 12))
1757
- expect(text).toContain(callSession)
1758
- })
1759
-
1760
- it('TC-w6-multi-run:多 run 拼接,content 含两段 overview,runs.length===2', async () => {
1761
- const slug = '--wf-multi--'
1762
- await wfMainSession(dir, slug, WF_ROOT)
1763
- await wfV2Run(dir, slug, WF_ROOT, 'wf-multi-1', [{ sessionId: WF_CALL, sessionFile: '/abs/a.jsonl' }], { settled: true })
1764
- await wfV2Run(dir, slug, WF_ROOT, 'wf-multi-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }], { settled: true })
1765
-
1766
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1767
- const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
1768
- expect(d.runs).toHaveLength(2)
1769
- expect(d.runIds).toHaveLength(2)
1770
- expect(d.runIds).toContain('wf-multi-1')
1771
- expect(d.runIds).toContain('wf-multi-2')
1772
- const text = r.content[0].text
1773
- expect(text).toContain('run: wf-multi-1')
1774
- expect(text).toContain('run: wf-multi-2')
1775
- })
1776
-
1777
- it('TC-w6-runid-filter:runId 过滤命中单 run', async () => {
1778
- const slug = '--wf-filter--'
1779
- await wfMainSession(dir, slug, WF_ROOT)
1780
- await wfV2Run(dir, slug, WF_ROOT, 'wf-filter-1', [{ sessionId: WF_CALL, sessionFile: '/abs/a.jsonl' }], { settled: true })
1781
- await wfV2Run(dir, slug, WF_ROOT, 'wf-filter-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }], { settled: true })
1782
-
1783
- const r = await handleSessionRead(
1784
- { action: 'workflow', session: WF_ROOT, runId: 'wf-filter-1' },
1785
- { agentDir: dir },
1786
- )
1787
- const d = r.details as {
1788
- runs: Array<{ runId: string }>
1789
- runIds: string[]
1790
- requestedRunId?: string
1791
- }
1792
- expect(d.runs).toHaveLength(1)
1793
- expect(d.runIds).toEqual(['wf-filter-1'])
1794
- expect(d.requestedRunId).toBe('wf-filter-1')
1795
- const text = r.content[0].text
1796
- expect(text).toContain('run: wf-filter-1')
1797
- expect(text).not.toContain('run: wf-filter-2')
1798
- })
1799
-
1800
- it('TC-w6-runid-not-found:runId 无匹配→ES-wf-runid-not-found(列候选+👉,不抛错)', async () => {
1801
- const slug = '--wf-notfound--'
1802
- await wfMainSession(dir, slug, WF_ROOT)
1803
- await wfV2Run(dir, slug, WF_ROOT, 'wf-nf-1', [{ sessionId: WF_CALL, sessionFile: '/abs/a.jsonl' }], { settled: true })
1804
- await wfV2Run(dir, slug, WF_ROOT, 'wf-nf-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }], { settled: true })
1805
-
1806
- const r = await handleSessionRead(
1807
- { action: 'workflow', session: WF_ROOT, runId: 'wf-nonexist' },
1808
- { agentDir: dir },
1809
- )
1810
- const d = r.details as { runs: unknown[]; runIds: string[]; requestedRunId?: string }
1811
- expect(d.runs).toEqual([])
1812
- expect(d.runIds).toContain('wf-nf-1')
1813
- expect(d.runIds).toContain('wf-nf-2')
1814
- expect(d.requestedRunId).toBe('wf-nonexist')
1815
- const text = r.content[0].text
1816
- expect(text).toContain('wf-nonexist')
1817
- expect(text).toContain('wf-nf-1')
1818
- expect(text).toContain('wf-nf-2')
1819
- expect(text).toContain('👉')
1820
- })
1821
-
1822
- it('TC-w6-no-runs:无 workflow run→ES-wf-no-runs(提示+👉family,不抛错)', async () => {
1823
- const slug = '--wf-noruns--'
1824
- await wfMainSession(dir, slug, WF_ROOT)
1825
- // 无 wf-link(session 存在但未发起 workflow)
1826
-
1827
- const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1828
- const d = r.details as { runs: unknown[]; runIds: unknown[]; sessionId?: string }
1829
- expect(d.runs).toEqual([])
1830
- expect(d.runIds).toEqual([])
1831
- expect(d.sessionId).toBe(WF_ROOT)
1832
- const text = r.content[0].text
1833
- expect(text).toContain('无 workflow run')
1834
- expect(text).toContain('👉')
1835
- expect(text).toContain('family')
1836
- })
1837
-
1838
- // [ADR-0095] TC-w6-snapshot-skip(快照不可读 → skippedRuns)随 v1 读侧删除:
1839
- // 发现链收敛 v2 后 run 一律 record 流直读(读失败 → running 兜底不 skipped),
1840
- // skippedRuns 快照链对 v2 档不可达。
1841
-
1842
- it('TC-w6-call-jump:workflow 概览 call sessionId 可被 resolveSessionId 深读(outline 跳转,§7 场景 2)', async () => {
1843
- const slug = '--wf-jump--'
1844
- await wfMainSession(dir, slug, WF_ROOT)
1845
- // 真实存在的 call session(main session 形态,findSessions 可匹配)
1846
- const callPath = await wfMainSession(dir, slug, WF_CALL, { cwd: '/proj/call' })
1847
- await wfV2Run(dir, slug, WF_ROOT, 'wf-jump-1', [{ sessionId: WF_CALL, sessionFile: callPath }], { settled: true })
1848
-
1849
- // 第一次:workflow 概览,拿 call sessionId
1850
- const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
1851
- const dWf = rWf.details as {
1852
- runs: Array<{ steps: Array<{ sessionId: string; sessionFile: string }> }>
1853
- }
1854
- expect(dWf.runs).toHaveLength(1)
1855
- const callSessionId = dWf.runs[0].steps[0].sessionId
1856
- expect(callSessionId).toBe(WF_CALL)
1857
-
1858
- // 第二次:用 call sessionId 调 outline(m0 resolveSessionId 三形态复用)
1859
- const rOutline = await handleSessionRead(
1860
- { action: 'outline', session: callSessionId },
1861
- { agentDir: dir },
1862
- )
1863
- const dOutline = rOutline.details as { turns: unknown[] }
1864
- expect(dOutline.turns.length).toBeGreaterThan(0)
1865
- })
1866
-
1867
- it('TC-w6-subagent-session:workflow action 对 subagent session 直读 wf-link 不抛错(MF-2)', async () => {
1868
- const slug = '--wf-subagent--'
1869
- // subagent session 放 subagents/ 下(不在 sessions/——buildFamilyFromFs 的 main byId
1870
- // 索引外,旧实现 resolveFamily 找不到会抛「session not found in family index」)
1871
- const subDir = resolveStoreSubagentSessionsDir(dir, SUB_WF_ROOT)
1872
- await mkdir(subDir, { recursive: true })
1873
- const subPath = join(subDir, `${SUB_WF_ROOT}.jsonl`)
1874
- await writeFile(
1875
- subPath,
1876
- JSON.stringify({ type: 'session', id: SUB_WF_ROOT, cwd: `/proj/${slug}` }) + '\n',
1877
- )
1878
- const callSession = join(dir, 'sessions', slug, `${WF_CALL}.jsonl`)
1879
- // subagent session 的 v2 注册条目([ADR-0095] v1 link 形态删除;record 流直读同源)
1880
- const recordPath = await wfStateFile(dir, slug, 'wf-sub-1.record.jsonl', [
1881
- JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-sub-1', workflowName: 'test-wf', argsSummary: '{}' }),
1882
- JSON.stringify({ type: 'agent-settled', seq: 2, ts: 1758000001000, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 100, result: { content: 'ok', sessionId: WF_CALL, sessionFile: callSession } }),
1883
- JSON.stringify({ type: 'run-settled', seq: 3, ts: 1758000002000, outcome: 'done' }),
1884
- ])
1885
- await writeFile(
1886
- subPath,
1887
- JSON.stringify({
1888
- type: 'custom',
1889
- id: 'wf-reg-wf-sub-1',
1890
- parentId: SUB_WF_ROOT,
1891
- customType: 'workflow-record',
1892
- data: { v: 2, kind: 'registered', runId: 'wf-sub-1', workflowName: 'test-wf', scriptName: 'test-wf', slug: 'wf-sub', startedAt: 1758000000000, recordPath },
1893
- timestamp: '2026-09-28T00:00:00Z',
1894
- }) + '\n',
1895
- { flag: 'a' },
1896
- )
1897
-
1898
- const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, { agentDir: dir })
1899
- const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
1900
- expect(d.runs).toHaveLength(1)
1901
- expect(d.runs[0].runId).toBe('wf-sub-1')
1902
- expect(d.runIds).toEqual(['wf-sub-1'])
1903
- expect(r.content[0].text).toContain('run: wf-sub-1')
1904
- expect(r.content[0].text).toContain('call=')
1905
- })
1906
- })
1907
-
1908
- // ============================================================
1909
- // m3b:doFamily recursive false/true(TC-m3b-dofamily-recursive-false/true)
1910
- // ============================================================
1911
-
1912
- describe('doFamily recursive(m3b U8 接入)', () => {
1913
- let dataDir: string
1914
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
1915
- const MAIN = '019e6c96-cccc-dddd-eeee-000000000001'
1916
- const SUB = '019e6c96-cccc-dddd-eeee-000000000002'
1917
-
1918
- beforeEach(async () => {
1919
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-recursive-'))
1920
- dir = join(dataDir, 'agent')
1921
- await makeFixtureSession(dir, MAIN, 'main session 内容')
1922
- // 顶层 subagent(rootSessionId=MAIN,无 parentRecordId → flat 回退)
1923
- await makeFixtureSubagent(dir, `sa-${SUB.slice(0, 8)}`, {
1924
- realSessionId: SUB,
1925
- rootSessionId: MAIN,
1926
- agentName: 'explorer',
1927
- firstUserText: 'subagent task',
57
+ it('ambiguous(任意 action 的多匹配消歧)→ N candidates', () => {
58
+ const r = toolResult({ ambiguous: true, candidates: [{}, {}] })
59
+ expect(buildSessionReadSummary(params({ action: 'outline', session: 'e6c9' }), r)).toBe(
60
+ 'Ambiguous session "e6c9": 2 candidates',
61
+ )
1928
62
  })
1929
- })
1930
- afterEach(async () => {
1931
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1932
- })
1933
-
1934
- it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
1935
- const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
1936
- // details 是 Family 对象(root/subagents/workflows),非 { tree }
1937
- const d = r.details as {
1938
- root: { sessionId: string }
1939
- subagents: Array<{ sessionId: string; rootSessionId: string }>
1940
- workflows: unknown[]
1941
- }
1942
- expect(d.root.sessionId).toBe(MAIN)
1943
- expect(d.subagents.some((s) => s.sessionId === SUB)).toBe(true)
1944
- expect(Array.isArray(d.workflows)).toBe(true)
1945
- // content 是 formatFamilyText(非 formatExecutionTreeText)
1946
- expect(r.content[0].text).toContain('root:')
1947
- expect(r.content[0].text).not.toContain('execution tree')
1948
- })
1949
63
 
1950
- it('TC-m3b-dofamily-recursive-true:recursive=true → ExecutionTree(details.tree)', async () => {
1951
- const r = await handleSessionRead(
1952
- { action: 'family', session: MAIN, recursive: true },
1953
- { agentDir: dir },
1954
- )
1955
- const d = r.details as {
1956
- tree: {
1957
- root: { type: string; sessionId: string; children: unknown[] }
1958
- totalNodes: number
1959
- maxDepth: number
1960
- sourceMode: string
1961
- truncated: boolean
64
+ it('family flat → N relations(parents/forks/subagents/workflows 合计)', () => {
65
+ const family = {
66
+ root: { sessionId: 's1' },
67
+ parents: [{}],
68
+ forks: [{}, {}],
69
+ subagents: [{}],
70
+ workflows: [],
1962
71
  }
1963
- }
1964
- expect(d.tree).toBeDefined()
1965
- expect(d.tree.root.type).toBe('main')
1966
- expect(d.tree.root.sessionId).toBe(MAIN)
1967
- // subagent 挂 root(flat 回退,无 parentRecordId)
1968
- expect(d.tree.root.children).toHaveLength(1)
1969
- expect(d.tree.totalNodes).toBe(2) // main + subagent
1970
- expect(d.tree.sourceMode).toBe('flat-fallback') // 旧机制无 parentRecordId
1971
- expect(d.tree.truncated).toBe(false)
1972
- // content 是 formatExecutionTreeText(含 execution tree 头部)
1973
- expect(r.content[0].text).toContain('execution tree')
1974
- expect(r.content[0].text).toContain('node(s)')
1975
- expect(r.content[0].text).toContain('👉')
1976
- })
1977
- })
1978
-
1979
- // ===========================================================================
1980
- // ext-simplify-04 U4/E2(D2②):formatFamilyText subagents 行富字段展示
1981
- // status 终态短标签 + agent 名 + task 截 60 摘要;孤儿只标 [已清理];P4 输出量级检查。
1982
- // ===========================================================================
1983
-
1984
- describe('formatFamilyText subagents 行富字段(ext-simplify-04 U4 / E2)', () => {
1985
- const MAIN = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f1'
1986
- const SUB = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f2'
1987
- let dataDir: string
1988
- let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
1989
-
1990
- beforeEach(async () => {
1991
- dataDir = await mkdtemp(join(tmpdir(), 'tool-handler-family-rich-'))
1992
- dir = join(dataDir, 'agent')
1993
- await makeFixtureSession(dir, MAIN, 'main session')
1994
- })
1995
- afterEach(async () => {
1996
- await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1997
- })
1998
-
1999
- /** alive subagent:session 文件(header + 可选 identity 尾行)+ 可选 manifest 富字段 */
2000
- async function writeSubagent(
2001
- realId: string,
2002
- opts: {
2003
- manifest?: { agentName?: string; task?: string; slug?: string; model?: string; status?: string }
2004
- identityTask?: string
2005
- identityAgent?: string
2006
- } = {},
2007
- ): Promise<void> {
2008
- const sessionDir = resolveStoreSubagentSessionsDir(dir, MAIN)
2009
- await mkdir(sessionDir, { recursive: true })
2010
- const subPath = join(sessionDir, `${realId}.jsonl`)
2011
- const lines = [JSON.stringify({ type: 'session', id: realId, cwd: '/demo' })]
2012
- if (opts.identityTask !== undefined || opts.identityAgent !== undefined) {
2013
- lines.push(
2014
- JSON.stringify({
2015
- type: 'custom',
2016
- customType: 'subagent-identity',
2017
- data: {
2018
- id: `sa-${realId.slice(0, 8)}`,
2019
- rootSessionId: MAIN,
2020
- slug: 'identity-slug',
2021
- agent: opts.identityAgent ?? 'explorer',
2022
- task: opts.identityTask ?? 't',
2023
- },
2024
- }),
72
+ const r = toolResult(family)
73
+ expect(buildSessionReadSummary(params({ action: 'family', session: 's1' }), r)).toBe(
74
+ 'Family of session s1: 4 relations',
2025
75
  )
2026
- }
2027
- await writeFile(subPath, lines.join('\n') + '\n')
2028
- if (opts.manifest) {
2029
- const recordsDir = resolveStoreSubagentRecordsDir(dir, MAIN)
2030
- await mkdir(recordsDir, { recursive: true })
2031
- await writeFile(
2032
- join(recordsDir, `sa-${realId}.json`),
2033
- JSON.stringify({
2034
- id: `sa-${realId}`,
2035
- rootSessionId: MAIN,
2036
- sessionFile: subPath,
2037
- ...opts.manifest,
2038
- }),
76
+ })
77
+
78
+ it('family recursive(details.tree)→ Execution tree', () => {
79
+ const r = toolResult({ tree: { root: {} } })
80
+ expect(buildSessionReadSummary(params({ action: 'family', session: 's1', recursive: true }), r)).toBe(
81
+ 'Execution tree of session s1',
2039
82
  )
2040
- }
2041
- }
83
+ })
2042
84
 
2043
- /** 孤儿 manifest:sessionFile 指向不存在路径(模拟 .jsonl 被 GC) */
2044
- async function writeOrphanManifest(
2045
- saId: string,
2046
- fields: { agentName?: string; task?: string; slug?: string; status?: string } = {},
2047
- ): Promise<void> {
2048
- const recordsDir = resolveStoreSubagentRecordsDir(dir, MAIN)
2049
- await mkdir(recordsDir, { recursive: true })
2050
- await writeFile(
2051
- join(recordsDir, `${saId}.json`),
2052
- JSON.stringify({ id: saId, rootSessionId: MAIN, sessionFile: '/nonexistent/gc.jsonl', ...fields }),
2053
- )
2054
- }
85
+ it('outline → N turns(stats.totalTurns)', () => {
86
+ const r = toolResult({ turns: [], lines: [], stats: { totalTurns: 32, totalEntries: 90, totalBytes: 1, parsedBytes: 1, skippedLines: 0 }, tokenEstimate: 1 })
87
+ expect(buildSessionReadSummary(params({ action: 'outline', session: 's1' }), r)).toBe(
88
+ 'Outline of session s1 · 32 turns',
89
+ )
90
+ })
2055
91
 
2056
- it('manifest 主路径:subagents 行含 [status] agent 名 · task 摘要;root 段不变', async () => {
2057
- await writeSubagent(SUB, {
2058
- manifest: { agentName: 'coder', task: '修复登录表单校验并补测试', slug: 'fix-login', status: 'completed' },
92
+ it('expand → T 序号补零 + N entries', () => {
93
+ const r = toolResult({ turn: { index: 13 }, entries: [{}, {}, {}, {}] })
94
+ expect(buildSessionReadSummary(params({ action: 'expand', session: 's1', turn: 'T013' }), r)).toBe(
95
+ 'Expanded turn T013 · 4 entries',
96
+ )
2059
97
  })
2060
- const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
2061
- const text = r.content[0].text
2062
- // 富字段行形态(设计 §3.1):slug 后接 [status] agent · task 摘要
2063
- expect(text).toContain('slug=fix-login [completed] coder · 修复登录表单校验并补测试')
2064
- // root/parents/forks/workflows 段不变(root 行含完整 id + 日期括号)
2065
- expect(text).toContain(`root: ${MAIN} (`)
2066
- })
2067
98
 
2068
- it('P4 输出量级:task 截 60 字符(超出加省略号),换行压平不破坏行结构', async () => {
2069
- const SUB2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000f3'
2070
- await writeSubagent(SUB, {
2071
- manifest: { agentName: 'coder', task: 'A'.repeat(100), slug: 'long-task', status: 'running' },
99
+ it('detail → turns 范围标签 + N entries', () => {
100
+ const r = toolResult({ turns: 'T013-T015', entries: [{}, {}, {}] })
101
+ expect(buildSessionReadSummary(params({ action: 'detail', session: 's1', turns: 'T013-T015' }), r)).toBe(
102
+ 'Read turns T013-T015 · 3 entries',
103
+ )
2072
104
  })
2073
- await writeSubagent(SUB2, {
2074
- manifest: { agentName: 'worker', task: 'line1\nline2\ttabbed', slug: 'nl-task', status: 'failed' },
105
+
106
+ it('search → Found N hits / No hits', () => {
107
+ const r = toolResult({ hits: [{}, {}], truncated: false })
108
+ expect(buildSessionReadSummary(params({ action: 'search', session: 's1', pattern: 'fix' }), r)).toBe(
109
+ 'Found 2 hits for "fix"',
110
+ )
111
+ const empty = toolResult({ hits: [], truncated: false })
112
+ expect(buildSessionReadSummary(params({ action: 'search', session: 's1', pattern: 'fix' }), empty)).toBe(
113
+ 'No hits for "fix"',
114
+ )
2075
115
  })
2076
- const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
2077
- const text = r.content[0].text
2078
- // 截断:恰 60 字符 + 省略号,原文 61+ 连续段不出现
2079
- expect(text).toContain(`${'A'.repeat(60)}…`)
2080
- expect(text).not.toContain('A'.repeat(61))
2081
- // 换行/制表压平为单空格
2082
- expect(text).toContain('slug=nl-task [failed] worker · line1 line2 tabbed')
2083
- // 行结构:subagents 段 = 头行 + 每 subagent 恰一行(换行未折叠出额外行)
2084
- const lines = text.split('\n')
2085
- const headIdx = lines.indexOf('subagents:')
2086
- const subLines = lines.slice(headIdx + 1)
2087
- expect(subLines).toHaveLength(2)
2088
- expect(subLines.every((l) => l.startsWith(' ') && l.trim().length > 0)).toBe(true)
2089
- })
2090
116
 
2091
- it('孤儿(cleanedUp):仍标 [已清理],不展开 status/agent/task 摘要', async () => {
2092
- await writeOrphanManifest('sa-ghost-id', {
2093
- agentName: 'worker',
2094
- task: 'ghost task',
2095
- slug: 'ghost-slug',
2096
- status: 'completed',
117
+ it('export → format + sizeBytes', () => {
118
+ const r = toolResult({ path: '/tmp/x.md', sizeBytes: 2048 })
119
+ expect(buildSessionReadSummary(params({ action: 'export', session: 's1' }), r)).toBe(
120
+ 'Exported outline view · 2048 bytes',
121
+ )
122
+ expect(buildSessionReadSummary(params({ action: 'export', session: 's1', format: 'full' }), r)).toBe(
123
+ 'Exported full view · 2048 bytes',
124
+ )
2097
125
  })
2098
- const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
2099
- const text = r.content[0].text
2100
- expect(text).toContain('slug=ghost-slug [已清理]')
2101
- // 已清理即终局:manifest 富字段不进文本(无深读入口,摘要无消费价值)
2102
- expect(text).not.toContain('ghost task')
2103
- expect(text).not.toContain('[completed]')
2104
- })
2105
126
 
2106
- it('P-fallback(无 manifest):identity 回退 task/agent 仍展示,status 无则无标签', async () => {
2107
- await writeSubagent(SUB, { identityTask: 'fix bug', identityAgent: 'worker' })
2108
- const r = await handleSessionRead({ action: 'family', session: MAIN }, { agentDir: dir })
2109
- const text = r.content[0].text
2110
- // P-fallback:task/agent 从 identity 回退(slug 取 identity.slug),status 不可回退 → 无 [x] 标签
2111
- expect(text).toContain('slug=identity-slug worker · fix bug')
2112
- expect(text).not.toContain('[completed]')
2113
- expect(text).not.toContain('[running]')
2114
- })
2115
- })
127
+ it('extract → count / 零匹配 / 截断 showing', () => {
128
+ const r = toolResult({ what: 'user-messages', count: 26, shown: 26, truncated: false, items: [] })
129
+ expect(buildSessionReadSummary(params({ action: 'extract', session: 's1', what: 'user-messages' }), r)).toBe(
130
+ 'Extracted 26 user-messages',
131
+ )
132
+ const empty = toolResult({ what: 'commands', count: 0, shown: 0, truncated: false, items: [] })
133
+ expect(buildSessionReadSummary(params({ action: 'extract', session: 's1', what: 'commands' }), empty)).toBe(
134
+ 'No commands found',
135
+ )
136
+ const clipped = toolResult({ what: 'files', count: 500, shown: 120, truncated: true, items: [] })
137
+ expect(buildSessionReadSummary(params({ action: 'extract', session: 's1', what: 'files' }), clipped)).toBe(
138
+ 'Extracted 500 files (showing 120)',
139
+ )
140
+ })
2116
141
 
2117
- // ===========================================================================
2118
- // doctor action(u8:design 2026-09-10 §6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)
2119
- // 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
2120
- // ===========================================================================
142
+ it('workflow → runs 计数 / 空 / skipped 后缀', () => {
143
+ expect(buildSessionReadSummary(params({ action: 'workflow', session: 's1' }), toolResult({ runs: [{}, {}], runIds: ['a', 'b'] }))).toBe(
144
+ 'Found 2 workflow runs',
145
+ )
146
+ expect(buildSessionReadSummary(params({ action: 'workflow', session: 's1' }), toolResult({ runs: [], runIds: [] }))).toBe(
147
+ 'No workflow runs',
148
+ )
149
+ expect(
150
+ buildSessionReadSummary(
151
+ params({ action: 'workflow', session: 's1' }),
152
+ toolResult({ runs: [{}], runIds: ['a'], skippedRuns: [{ runId: 'b', stateFile: 'x', reason: 'snapshot-unreadable' }] }),
153
+ ),
154
+ ).toBe('Found 1 workflow runs · 1 skipped')
155
+ })
2121
156
 
2122
- // doctor details 契约直用 doctor.ts 导出的 DoctorDetails(不再自建局部镜像——生产侧
2123
- // 形状漂移由编译期捕捉,S-R2 同步)
157
+ it('result 单条 → session + totalChars(截断后缀)', () => {
158
+ const r = toolResult({ session: 'sa-abc', sessionId: 'uuid', sessionFile: '/x', totalChars: 8000, truncated: true, text: '...' })
159
+ expect(buildSessionReadSummary(params({ action: 'result', session: 'sa-abc' }), r)).toBe(
160
+ 'Read final result of sa-abc · 8000 chars (truncated)',
161
+ )
162
+ })
2124
163
 
2125
- describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob)', () => {
2126
- let tmp: string
164
+ it('result 批量 → count + items 形态识别', () => {
165
+ const r = toolResult({ count: 3, items: [{}, {}, {}] })
166
+ expect(buildSessionReadSummary(params({ action: 'result', session: 'a,b,c' }), r)).toBe(
167
+ 'Read 3 final results',
168
+ )
169
+ })
2127
170
 
2128
- const SLUG = '--Users-foo--'
171
+ it('doctor → roots 计数', () => {
172
+ const r = toolResult({ environment: {}, roots: [{ kind: 'default' }, { kind: 'legacy' }] })
173
+ expect(buildSessionReadSummary(params({ action: 'doctor' }), r)).toBe(
174
+ 'Doctor: 2 roots checked',
175
+ )
176
+ })
2129
177
 
2130
- /** 写 .jsonl fixture(父目录自建),内容合法 session header */
2131
- async function writeJsonl(path: string, id = 'x'): Promise<void> {
2132
- await mkdir(dirname(path), { recursive: true })
2133
- await writeFile(path, `{"type":"session","id":"${id}"}\n`)
2134
- }
178
+ it('details 形态漂移(空对象)→ 各 action 兜底不抛错', () => {
179
+ const empty = toolResult({})
180
+ expect(buildSessionReadSummary(params({ action: 'outline', session: 's1' }), empty)).toBe(
181
+ 'Outline of session s1',
182
+ )
183
+ expect(buildSessionReadSummary(params({ action: 'workflow', session: 's1' }), empty)).toBe(
184
+ 'No workflow runs',
185
+ )
186
+ expect(buildSessionReadSummary(params({ action: 'doctor' }), empty)).toBe('Doctor check completed')
187
+ expect(buildSessionReadSummary(params({ action: 'extract', what: 'files' }), empty)).toBe('No files found')
188
+ })
189
+ })
2135
190
 
2136
- /**
2137
- * taiji 形态四根 fixture:dataDir 目录名带 .taiji 前缀(detectEnvironment 的
2138
- * PI_CODING_AGENT_DIR 形态判据 `<*>/.taiji*\/agent` 可命中,托管判定走正态)。
2139
- * live=agent/sessions 与 default 同字面路径(default 去重)、legacy 独立非空、subagent 独立。
2140
- */
2141
- async function buildFourRootFixture(): Promise<{
2142
- dataDir: string
2143
- agentDir: string
2144
- signals: SessionReadSignals
2145
- }> {
2146
- const dataDir = join(tmp, '.taiji-fixture')
2147
- const agentDir = join(dataDir, 'agent')
2148
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'), 'aaaaaaaa')
2149
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
2150
- await writeJsonl(join(dataDir, 'sessions', 'old.jsonl'), 'cccccccc') // legacy 非空
2151
- await writeJsonl(
2152
- join(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-000000000d1'), 's.jsonl'),
2153
- 'dddddddd',
2154
- )
2155
- return {
2156
- dataDir,
2157
- agentDir,
2158
- signals: {
2159
- agentDir,
2160
- liveSessionDir: join(agentDir, 'sessions'),
2161
- env: {
2162
- TAIJI_AGENT_EXT_LOG: '1',
2163
- PI_CODING_AGENT_DIR: agentDir,
2164
- TAIJI_AGENT_DATA_DIR: dataDir,
2165
- },
2166
- },
2167
- }
2168
- }
191
+ // ---- withResultSummary ----
2169
192
 
2170
- beforeEach(async () => {
2171
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-doctor-'))
2172
- })
2173
- afterEach(async () => {
2174
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
193
+ describe('withResultSummary', () => {
194
+ it('details 追加 summary,现有字段保留', () => {
195
+ const r = toolResult({ hits: [{}], truncated: false })
196
+ const out = withResultSummary(params({ action: 'search', pattern: 'fix' }), r)
197
+ expect(out.details).toEqual({ hits: [{}], truncated: false, summary: 'Found 1 hits for "fix"' })
198
+ })
2175
199
  })
2176
200
 
2177
- it('四根表渲染:优先级序 + 去重注记 + 文件数/耗时 + 托管判定行 + 事实型诊断(无归因断言)', async () => {
2178
- const { dataDir, agentDir, signals } = await buildFourRootFixture()
2179
- const r = await handleSessionRead({ action: 'doctor' }, signals)
2180
- const text = r.content[0].text
2181
-
2182
- // 环境判定行:双信号合取 → taiji(托管)+ 数据目录 + evidence 正态
2183
- expect(text).toContain('环境判定:taiji(托管)')
2184
- expect(text).toContain(`数据目录:${dataDir}`)
2185
- expect(text).toContain('依据:')
2186
- expect(text).toContain(`PI_CODING_AGENT_DIR='${agentDir}' → 形态匹配`)
2187
-
2188
- // 四根按优先级序渲染
2189
- const liveIdx = text.indexOf('[live]')
2190
- const defIdx = text.indexOf('[default]')
2191
- const legacyIdx = text.indexOf('[legacy]')
2192
- const subIdx = text.indexOf('[subagent]')
2193
- expect(liveIdx).toBeGreaterThan(-1)
2194
- expect(defIdx).toBeGreaterThan(liveIdx)
2195
- expect(legacyIdx).toBeGreaterThan(defIdx)
2196
- expect(subIdx).toBeGreaterThan(legacyIdx)
2197
-
2198
- // 去重注记(§7B 要点 4):default 与第 1 行(live)同路径,已去重
2199
- expect(text).toContain('与 1 同路径,已去重')
2200
-
2201
- // 事实型诊断:只陈述最高优先级 main 根 N 文件;禁止归因断言(§7B 要点 5)
2202
- expect(text).toContain(
2203
- `诊断:最高优先级 main 根 [live] ${join(agentDir, 'sessions')}:2 文件。`,
2204
- )
2205
- expect(text).not.toContain('真的没有')
2206
-
2207
- // details 程序化形态
2208
- const d = r.details as DoctorDetails
2209
- expect(d.roots.map((x) => x.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
2210
- expect(d.roots[0].fileCount).toBe(2)
2211
- expect(typeof d.roots[0].scanMs).toBe('number')
2212
- expect(d.roots[1].dedupedInto).toBe('live')
2213
- expect(d.roots[1].fileCount).toBeUndefined()
2214
- expect(d.roots[2].fileCount).toBe(1) // legacy 独立实扫
2215
- // subagent 默认 stat 模式:只列路径与可扫性,无计数
2216
- expect(d.roots[3].exists).toBe(true)
2217
- expect(d.roots[3].fileCount).toBeUndefined()
2218
- expect(d.roots[3].scanMs).toBeUndefined()
2219
- })
201
+ // ---- renderSessionReadResult(TUI renderResult)----
202
+
203
+ const mockTheme = {
204
+ fg: (_color: string, text: string) => text,
205
+ bg: (_color: string, text: string) => text,
206
+ bold: (text: string) => text,
207
+ italic: (text: string) => text,
208
+ underline: (text: string) => text,
209
+ inverse: (text: string) => text,
210
+ strikethrough: (text: string) => text,
211
+ getFgAnsi: (_color: string) => '',
212
+ getBgAnsi: (_color: string) => '',
213
+ getColorMode: () => 'truecolor' as const,
214
+ getThinkingBorderColor: () => (text: string) => text,
215
+ getBashModeBorderColor: () => (text: string) => text,
216
+ } as unknown as Theme
217
+
218
+ function renderedText(result: unknown, expanded: boolean): string {
219
+ return renderSessionReadResult(result, { expanded }, mockTheme)
220
+ .render(400)
221
+ .map((line) => line.trimEnd())
222
+ .join('\n')
223
+ }
224
+
225
+ describe('renderSessionReadResult', () => {
226
+ it('折叠态 → details.summary 一行', () => {
227
+ const r = toolResult({ matches: [], truncated: false }, 'multi\nline\nbody')
228
+ const withSummary = withResultSummary(params({ action: 'find', query: 'login' }), r)
229
+ expect(renderedText(withSummary, false)).toBe('No sessions matching "login"')
230
+ })
2220
231
 
2221
- it('legacy 非空告警:独立非空 → 告警;不存在 → 无告警;与 live 同路径被去重 → 不告警(§5.1)', async () => {
2222
- // 正态:legacy 独立且非空
2223
- const { signals } = await buildFourRootFixture()
2224
- const positive = await handleSessionRead({ action: 'doctor' }, signals)
2225
- expect(positive.content[0].text).toContain(
2226
- `告警:[legacy] 根 ${join(tmp, '.taiji-fixture', 'sessions')} 非空(1 文件)`,
2227
- )
232
+ it('展开态 → content 纯文本(多行完整)', () => {
233
+ const r = toolResult({ matches: [] }, 'line1\nline2')
234
+ expect(renderedText(r, true)).toBe('line1\nline2')
235
+ })
2228
236
 
2229
- // 反态:legacy 目录不存在
2230
- const agentDir2 = join(tmp, 'data2', 'agent')
2231
- await writeJsonl(join(agentDir2, 'sessions', SLUG, 'a.jsonl'))
2232
- const negative = await handleSessionRead({ action: 'doctor' }, { agentDir: agentDir2 })
2233
- expect(negative.content[0].text).not.toContain('告警:')
2234
-
2235
- // 去重态:live 与 legacy 同路径(文件非空)→ 已去重不告警
2236
- const dataDir3 = join(tmp, 'data3')
2237
- const agentDir3 = join(dataDir3, 'agent')
2238
- await writeJsonl(join(dataDir3, 'sessions', SLUG, 'a.jsonl'))
2239
- const deduped = await handleSessionRead(
2240
- { action: 'doctor' },
2241
- { agentDir: agentDir3, liveSessionDir: join(dataDir3, 'sessions') },
2242
- )
2243
- expect(deduped.content[0].text).not.toContain('告警:')
2244
- expect(deduped.content[0].text).toContain('已去重')
237
+ it('无 summary(旧 entry)→ 折叠态回退 content 首行', () => {
238
+ const r = toolResult({}, 'Header line\ndetail body')
239
+ expect(renderedText(r, false)).toBe('Header line')
240
+ })
2245
241
  })
242
+ });
243
+
244
+ // ======== 来源:zcode-routing.test.ts ========
245
+ describe("zcode-routing", () => {
246
+ // ============================================================
247
+ // U9 路由 + 白名单闸 + 错误范围集成测试(design session-reader-shared-core
248
+ // §3.1 三层定位链 / §3.4 七码 + 检查顺序三段递进 / §4 场景 1a 变体 M/E +
249
+ // 场景 2 family + 场景 5 八类失败 + 场景 3 pi 零回归 + D4 schema 零变化)。
250
+ //
251
+ // fixture 骨架(mkdtemp 自建自删):root/agent = agentDir(dirname = dataDir)、
252
+ // root/engines/zcode/session-db/db.sqlite = 隔离库(白名单精确命中)、
253
+ // root/live-sessions = liveSessionDir(entry 兜底候选根)、
254
+ // root/agent/sessions = main 默认根(family/rootSession 视图的 header 来源)。
255
+ //
256
+ // 行集非平凡性:sess_aaa(ZASENTINEL)与 sess_bbb(ZBSENTINEL + ZZQFIXTURE +
257
+ // toolCall 对 + compaction part)两个可区分行集——「取首条/合并/读错行集」的
258
+ // 实现会直接失败。manifest 锚与 entry 锚刻意指向不同 session(变体 M 的
259
+ // instrument:读到 manifest 的 sess_bbb 而非 entry 的 sess_aaa = 走对主路径)。
260
+ // ============================================================
2246
261
 
2247
- it('subagent 根默认不扫(只列路径与可扫性);includeSubagents:true 才扫出文件数(§6.3)', async () => {
2248
- const { agentDir, signals } = await buildFourRootFixture()
262
+ const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
2249
263
 
2250
- const def = await handleSessionRead({ action: 'doctor' }, signals)
2251
- const defSub = (def.details as DoctorDetails).roots.find((x) => x.kind === 'subagent')
2252
- expect(defSub?.fileCount).toBeUndefined()
2253
- expect(defSub?.scanMs).toBeUndefined()
2254
- expect(defSub?.exists).toBe(true)
2255
- expect(def.content[0].text).toContain('未扫描(subagent 根默认不扫')
264
+ const SA_M = 'sa-fix1' // 变体 M / 八类失败主 sa-id
265
+ const SA_E = 'sa-fix2' // 变体 E(manifest 缺位,entry 兜底)
266
+ const SESS_A = 'sess_aaa'
267
+ const SESS_B = 'sess_bbb'
268
+ const ROOT_SESSION = 'root-session-1'
269
+ const BASE_MS = 1758379200000
2256
270
 
2257
- const full = await handleSessionRead({ action: 'doctor', includeSubagents: true }, signals)
2258
- const fullSub = (full.details as DoctorDetails).roots.find((x) => x.kind === 'subagent')
2259
- expect(fullSub?.fileCount).toBe(1)
2260
- expect(fullSub?.exists).toBe(true)
2261
- })
271
+ let root: string
272
+ let agentDir: string
273
+ let dbPath: string
274
+ let liveDir: string
275
+ let appendEntries: ReturnType<typeof vi.fn>
2262
276
 
2263
- it('无缓存形态(ext-simplify-04 U3):重复调用输出一致(每次实扫),新落盘文件立即可见', async () => {
2264
- // 原三个用例(缓存命中不重扫 / TTL 到期失效 / find 不读 doctor 缓存 PS-14)随
2265
- // doctor 缓存机删除而合并为本用例——无缓存层后「find/doctor 读到旧计数」的形态
2266
- // 在结构上不存在,检查点收敛为:输出无「缓存命中」行 + 每次调用实扫(新增文件
2267
- // 下一次调用即见,反而更实时)
2268
- const agentDir = join(tmp, 'agent')
2269
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
2270
- const signals: SessionReadSignals = { agentDir }
2271
-
2272
- const first = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
2273
- const firstDef = first.roots.find((x) => x.kind === 'default')!
2274
- expect(firstDef.fileCount).toBe(1)
2275
-
2276
- const second = await handleSessionRead({ action: 'doctor' }, signals)
2277
- const secondDef = (second.details as DoctorDetails).roots.find((x) => x.kind === 'default')!
2278
- expect(secondDef.fileCount).toBe(1) // 两次输出一致(同目录无变化)
2279
- expect(second.content[0].text).not.toContain('缓存命中')
2280
-
2281
- // 新 session 落盘 → 下一次 doctor / find 均立即可见(无缓存层,每次实扫)
2282
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
2283
- const third = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
2284
- expect(third.roots.find((x) => x.kind === 'default')!.fileCount).toBe(2)
2285
- const r = await handleSessionRead({ action: 'find', query: 'bbbbbbbb' }, { agentDir })
2286
- const d = r.details as { matches: Array<{ sessionId: string }> }
2287
- expect(d.matches.some((m) => m.sessionId.includes('bbbbbbbb'))).toBe(true)
277
+ beforeEach(() => {
278
+ root = mkdtempSync(join(tmpdir(), 'zcode-routing-test-'))
279
+ agentDir = join(root, 'agent')
280
+ liveDir = join(root, 'live-sessions')
281
+ mkdirSync(agentDir, { recursive: true })
282
+ mkdirSync(liveDir, { recursive: true })
283
+ dbPath = join(root, ...ISOLATED_DB_SEGMENTS)
284
+ // 结构化日志捕获:logger.warn 走 appendEntry 通道(不进 LLM 可见部分——本文件同时
285
+ // 是「降级留痕不进 LLM 可见部分」契约的断言范围)。测试后清注入防跨文件泄漏。
286
+ appendEntries = vi.fn()
287
+ setPiHandle({ appendEntry: appendEntries } as unknown as Parameters<typeof setPiHandle>[0])
2288
288
  })
2289
289
 
2290
- it('env/bundleUrl 信号缺失降级:仍出环境判定行(standalone-pi · 未知)+ 完整根表,不抛错', async () => {
2291
- const agentDir = join(tmp, 'agent')
2292
- await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
2293
- // signals 无 env/bundleUrl 字段(u3 旧信号包形态)
2294
- const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
2295
- const text = r.content[0].text
2296
- expect(text).toContain('环境判定:standalone-pi · 发行形态:未知(不猜)')
2297
- expect(text).toContain('TAIJI_AGENT_EXT_LOG=<未设置> → 未命中')
2298
- expect(text).toContain('会话根(按优先级)')
2299
- expect(text).toContain('[default]')
2300
- expect(text).toContain(
2301
- `诊断:最高优先级 main 根 [default] ${join(agentDir, 'sessions')}:1 文件。`,
2302
- )
290
+ afterEach(() => {
291
+ setPiHandle(undefined)
292
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2303
293
  })
2304
294
 
2305
- it('空 agentDir 防御:无根表、诊断「无候选根」(不扫 cwd 相对路径)', async () => {
2306
- const r = await handleSessionRead({ action: 'doctor' }, { agentDir: '' })
2307
- const text = r.content[0].text
2308
- expect(text).toContain('诊断:无候选根')
2309
- expect(text).not.toContain('[default]')
2310
- expect((r.details as DoctorDetails).roots).toEqual([])
2311
- })
2312
- })
295
+ // ---------------------------------------------------------------------------
296
+ // fixture builders
297
+ // ---------------------------------------------------------------------------
2313
298
 
2314
- // ============================================================
2315
- // u9:F1 重写(§5.2 事实型自检 + 编辑距离 top-3 + 四条做法 + 禁止项)
2316
- // + uuid 归一化两级匹配(§6.7 子决策 1 + §11.5 对比基线)
2317
- // ============================================================
299
+ interface SessionRows {
300
+ id: string
301
+ title: string
302
+ messages: Array<{ id: string; role: string; parts: Array<Record<string, unknown>> }>
303
+ }
2318
304
 
2319
- describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)', () => {
2320
- let tmp: string
2321
- const SLUG = '--demo-cwd--'
2322
- const ID1 = '019e6c96-aaaa-bbbb-cccc-000000000001'
2323
- const ID2 = '019e6c96-aaaa-bbbb-cccc-000000000002'
305
+ /** sess_aaa 行集:两轮对话,文本带 ZASENTINEL(entry 首条锚指向它)。 */
306
+ function sessARows(): SessionRows {
307
+ return {
308
+ id: SESS_A,
309
+ title: 'sess A title',
310
+ messages: [
311
+ userMsg('m1', 0, '第一轮用户提问 ZASENTINEL 锚'),
312
+ assistantMsg('m2', 1, ['第一轮助手回答 ZASENTINEL']),
313
+ userMsg('m3', 2, '第二轮用户提问'),
314
+ assistantMsg('m4', 3, ['第二轮助手回答 ZASENTINEL']),
315
+ ],
316
+ }
317
+ }
2324
318
 
2325
- /**
2326
- * 写 session 文件(文件名 = `${id}.jsonl`,extractSessionIdFromFilename 可提取——
2327
- * F1 编辑距离候选的 id 来源)。subagent:true 写入 subagent 根。
2328
- */
2329
- async function writeSession(
2330
- id: string,
2331
- opts?: { subagent?: boolean; firstUserText?: string },
2332
- ): Promise<void> {
2333
- // [session-store-layout-inversion] subagent 会话在新布局存储树(分区 sid 固定合法形态)
2334
- const dir = opts?.subagent
2335
- ? resolveStoreSubagentSessionsDir(join(tmp, 'agent'), '0aaaaaaa-bbbb-7ccc-dddd-0000000000s1')
2336
- : join(tmp, 'agent', 'sessions', SLUG)
2337
- await mkdir(dir, { recursive: true })
2338
- const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
2339
- if (opts?.firstUserText !== undefined) {
2340
- lines.push(
2341
- JSON.stringify({
2342
- type: 'message',
2343
- id: `${id}-m1`,
2344
- message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
319
+ /** sess_bbb 行集:两轮 + toolCall 对 + ZZQFIXTURE + compaction(manifest 锚指向它)。 */
320
+ function sessBRows(): SessionRows {
321
+ return {
322
+ id: SESS_B,
323
+ title: 'sess B title',
324
+ messages: [
325
+ userMsg('m1', 0, '派发任务 ZZQFIXTURE 检索锚点'),
326
+ assistantMsg('m2', 1, [], {
327
+ toolPart: {
328
+ type: 'tool',
329
+ callID: 'tc-1',
330
+ tool: 'Bash',
331
+ state: { status: 'completed', input: { command: 'echo hi' }, output: 'hi' },
332
+ },
333
+ extraParts: [{ type: 'text', text: '工具执行完成', time: { start: BASE_MS + 20 } }],
2345
334
  }),
2346
- )
335
+ userMsg('m3', 2, '继续第二轮'),
336
+ assistantMsg('m4', 3, ['第二轮 ZBSENTINEL 收尾'], {
337
+ extraParts: [{ type: 'compaction', data: { trigger: 'auto' }, time: { start: BASE_MS + 40 } }],
338
+ }),
339
+ ],
2347
340
  }
2348
- await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
2349
341
  }
2350
342
 
2351
- /** find action 便捷调用(agentDir = tmp/agent)。 */
2352
- function find(query: string, extra?: Partial<SessionReadParams>): Promise<ToolResultLike> {
2353
- return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
343
+ function userMsg(id: string, seq: number, text: string): SessionRows['messages'][number] {
344
+ return {
345
+ id,
346
+ role: 'user',
347
+ parts: [{ type: 'text', text, time: { start: BASE_MS + seq * 10 } }],
348
+ }
2354
349
  }
2355
350
 
2356
- interface ToolResultLike {
2357
- content: Array<{ type: string; text: string }>
2358
- details: unknown
351
+ function assistantMsg(
352
+ id: string,
353
+ seq: number,
354
+ texts: string[],
355
+ opts: { toolPart?: Record<string, unknown>; extraParts?: Array<Record<string, unknown>> } = {},
356
+ ): SessionRows['messages'][number] {
357
+ const parts: Array<Record<string, unknown>> = []
358
+ if (opts.toolPart) parts.push(opts.toolPart)
359
+ texts.forEach((t, i) => parts.push({ type: 'text', text: t, time: { start: BASE_MS + seq * 10 + i } }))
360
+ if (opts.extraParts) parts.push(...opts.extraParts)
361
+ parts.push({ type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1, total: 2 }, cost: 0 })
362
+ return { id, role: 'assistant', parts }
2359
363
  }
2360
364
 
2361
- beforeEach(async () => {
2362
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-f1-'))
2363
- })
2364
- afterEach(async () => {
2365
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2366
- })
2367
-
2368
- it('① 大写 uuid 片段命中(§3.3 盲区:String.includes 大小写敏感)', async () => {
2369
- await writeSession(ID1, { firstUserText: '无关内容' })
2370
- const r = await find('019E6C96-AAAA')
2371
- const d = r.details as { matches: Array<{ sessionId: string }> }
2372
- expect(d.matches).toHaveLength(1)
2373
- expect(d.matches[0].sessionId).toBe(ID1)
2374
- })
2375
-
2376
- it('② 去连字符完整 uuid 命中(§3.3 盲区:与带连字符 id 不构成子串)', async () => {
2377
- await writeSession(ID1, { firstUserText: '无关内容' })
2378
- // 去连字符形态与 sessionId/path 均不构成精确子串,只能走归一化层
2379
- const r = await find('019e6c96aaaabbbbcccc000000000001')
2380
- const d = r.details as { matches: Array<{ sessionId: string }> }
2381
- expect(d.matches).toHaveLength(1)
2382
- expect(d.matches[0].sessionId).toBe(ID1)
2383
- })
2384
-
2385
- it('③ F1 文案四要素 + 负向断言(无 recent 误导指引、无「真的没有」归因)', async () => {
2386
- await writeSession(ID1, { firstUserText: '无关内容' })
2387
- const r = await find('zzz-no-hit-9q8x')
2388
- const text = r.content[0].text
2389
-
2390
- // 首行 + ①事实型自检行(根计数 / 候选集判定 / 归一化声明)
2391
- expect(text).toContain('无匹配 session:"zzz-no-hit-9q8x"')
2392
- expect(text).toContain('自检(发现层,只陈述事实)')
2393
- expect(text).toContain('[default]')
2394
- expect(text).toContain('候选集非空(共 1 文件),根解析正常')
2395
- expect(text).toContain('查询已做 uuid 归一化匹配(小写 + 去连字符)后仍无命中')
2396
- // ②编辑距离候选段存在
2397
- expect(text).toContain('最接近的候选(编辑距离)')
2398
- // ③「正确做法」四条
2399
- expect(text).toContain('正确做法')
2400
- expect(text).toContain('改用标题/keyword')
2401
- expect(text).toContain('直接传绝对路径')
2402
- expect(text).toContain('更短前缀')
2403
- expect(text).toContain('action:"doctor"')
2404
- // ④最后一行封死 shell 绕行
2405
- expect(text).toContain('不要用 shell find/ls/rg 搜 session 目录,不要 cat/read 原始 .jsonl')
2406
- // 负向:无「recent 看全量」误导、无归因断言
2407
- expect(text).not.toContain('recent')
2408
- expect(text).not.toContain('真的没有')
2409
- // details 程序化契约保持(零匹配形态不变)
2410
- expect(r.details).toEqual({ matches: [], truncated: false })
2411
- })
365
+ /**
366
+ * 建真实 fixture 隔离库(WAL + session/message/part/schema_migration 四表,行形态 =
367
+ * zcode-session-source helpers 同款宿主 schema 0.16.5 消费方子集)。
368
+ * 测试进程跑 node(node:sqlite 可用);生产读链的驱动选择在 source 包内。
369
+ */
370
+ function createZcodeDb(path: string, schemaVersion = '0.16.5', sessions: SessionRows[] = []): void {
371
+ mkdirSync(join(path, '..'), { recursive: true })
372
+ const db = new DatabaseSync(path)
373
+ try {
374
+ db.exec('PRAGMA journal_mode=WAL')
375
+ db.exec('BEGIN')
376
+ db.exec(
377
+ 'CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, title TEXT NOT NULL, ' +
378
+ "task_type TEXT NOT NULL DEFAULT 'interactive', time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL)",
379
+ )
380
+ db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, sequence INTEGER, data TEXT NOT NULL)')
381
+ db.exec('CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, sequence INTEGER, data TEXT NOT NULL)')
382
+ db.exec('CREATE TABLE schema_migration (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, app_version TEXT, time_applied INTEGER NOT NULL)')
383
+ db.prepare('INSERT INTO schema_migration (id, checksum, app_version, time_applied) VALUES (?, ?, ?, ?)').run('0001_seed', 'x', schemaVersion, 1)
384
+ const insSession = db.prepare('INSERT INTO session (id, directory, title, task_type, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)')
385
+ const insMessage = db.prepare('INSERT INTO message (id, session_id, sequence, data) VALUES (?, ?, ?, ?)')
386
+ const insPart = db.prepare('INSERT INTO part (id, message_id, session_id, sequence, data) VALUES (?, ?, ?, ?, ?)')
387
+ for (const [si, s] of sessions.entries()) {
388
+ insSession.run(s.id, '/tmp/fix', s.title, 'interactive', BASE_MS, BASE_MS + si * 10)
389
+ for (const [mi, m] of s.messages.entries()) {
390
+ insMessage.run(`${s.id}_${m.id}`, s.id, mi, JSON.stringify({ role: m.role, time: { created: BASE_MS + mi * 10 } }))
391
+ for (const [pi, p] of m.parts.entries()) {
392
+ insPart.run(`${s.id}_${m.id}_p${pi}`, `${s.id}_${m.id}`, s.id, pi, JSON.stringify(p))
393
+ }
394
+ }
395
+ }
396
+ db.exec('COMMIT')
397
+ } finally {
398
+ db.close()
399
+ }
400
+ }
2412
401
 
2413
- it('④ 编辑距离 top-3:近似 id 入选(标注 source 与差异位),远 id 被挤出', async () => {
2414
- const near1 = '019aaabc-0000-7000-8000-00000000000a'
2415
- const near2 = '019aaabc-0000-7000-8000-00000000000b'
2416
- const near3 = '019aaabc-0000-7000-8000-00000000000c'
2417
- const far = 'ffffffff-ffff-ffff-ffff-ffffffffffff'
2418
- await writeSession(near1)
2419
- await writeSession(near2)
2420
- await writeSession(near3, { subagent: true })
2421
- await writeSession(far)
2422
- // 手算锚点:query 与 near* 前 33 字符相同,第 34 位起 z→0/z→0/z→a,距离 3
2423
- expect(levenshtein('019aaabc-0000-7000-8000-000000000zzz', near1)).toBe(3)
2424
- expect(levenshtein('kitten', 'sitting')).toBe(3)
2425
-
2426
- const r = await find('019aaabc-0000-7000-8000-000000000zzz')
2427
- const text = r.content[0].text
2428
- // top-3 恰为三个近似候选(far 距离约 36,第 4 名被上限挤掉)
2429
- expect(text).toContain(near1)
2430
- expect(text).toContain(near2)
2431
- expect(text).toContain(near3)
2432
- expect(text).not.toContain(far)
2433
- // 差异位标注(首个差异字符,1-based)
2434
- expect(text).toContain('差异在第 34 位:z → 0')
2435
- // source 标注:near3 在 subagent 根,标注透传
2436
- expect(text).toContain(`${near3} subagent`)
2437
- })
402
+ /** zcode manifest(F21 磁盘真实形态:无 sessionFile 键)。 */
403
+ function writeZcodeManifest(
404
+ saId: string,
405
+ opts: { sessionRef?: Record<string, string>; omitEngineHandle?: boolean; engine?: string | true } = {},
406
+ ): void {
407
+ // [session-store-layout-inversion D6/单新根] records 读侧 = 存储树分区固定位置
408
+ // <root>/<rootSid>/subagents/records/(旧根 <agentDir>/subagents 不再读写)。
409
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', ROOT_SESSION, 'subagents', 'records')
410
+ mkdirSync(recordsDir, { recursive: true })
411
+ const m: Record<string, unknown> = {
412
+ id: saId,
413
+ rootSessionId: ROOT_SESSION,
414
+ agentName: 'worker',
415
+ task: 'fixture task',
416
+ status: 'running',
417
+ }
418
+ m.engine = opts.engine === undefined ? 'zcode' : opts.engine
419
+ if (!opts.omitEngineHandle) {
420
+ m.engineHandle = {
421
+ sessionRef: opts.sessionRef ?? { sessionId: SESS_B, dbPath },
422
+ poolKey: 'shared',
423
+ }
424
+ }
425
+ writeFileSync(join(recordsDir, `${saId}.json`), JSON.stringify(m), 'utf8')
426
+ }
2438
427
 
2439
- it('⑤ §11.5 归一化对比基线:增量仅限大小写/连字符变体,非 uuid 乱串不被 norm 误吸', async () => {
2440
- const idHit = '019cafe0-1234-7000-8000-deadbeef0001'
2441
- await writeSession(idHit, { firstUserText: '福耀玻璃深度研究' })
2442
- await writeSession(ID2, { firstUserText: '完全不同的另一个会话' })
2443
-
2444
- // 现状基线:精确小写片段命中 1
2445
- const base = await find('019cafe0')
2446
- expect((base.details as { matches: unknown[] }).matches).toHaveLength(1)
2447
- // 大写变体:增量 = 同一 id(大小写变体),无新对象
2448
- const upper = await find('019CAFE0')
2449
- expect((upper.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
2450
- // 去连字符变体:增量 = 同一 id(连字符变体),无新对象
2451
- const noDash = await find('019cafe0123470008000deadbeef0001')
2452
- expect((noDash.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
2453
- // 'deadbeef' 段是带连字符 id 的连续子串——现状精确匹配已命中,norm 后命中数不变(无增量)
2454
- const deadbeef = await find('deadbeef')
2455
- expect((deadbeef.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
2456
- // keyword 路径不受 norm 影响(非 uuid 特征照常回退首消息)
2457
- const kw = await find('福耀玻璃')
2458
- expect((kw.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
2459
- // 非 uuid 乱串:0 命中(norm 不把乱串吸进 uuid 匹配)
2460
- const junk = await find('zzznotexist9q')
2461
- expect((junk.details as { matches: unknown[] }).matches).toHaveLength(0)
2462
- // hex 乱串(uuid 特征但非任何 id 子串):两级均 0 → 短路不回退(与现状一致,无召回回归)
2463
- const hexJunk = await find('0123456789abcdef')
2464
- expect((hexJunk.details as { matches: unknown[] }).matches).toHaveLength(0)
2465
- })
428
+ /** subagent-record custom entry 行(U7 entry-anchor.test 同款形状)。 */
429
+ function recordLine(entryId: string, saId: string, sessionRef: Record<string, string>): string {
430
+ return JSON.stringify({
431
+ type: 'custom',
432
+ customType: 'subagent-record',
433
+ id: entryId,
434
+ parentId: null,
435
+ data: {
436
+ v: 2, kind: 'settled', id: saId, status: 'idle', stopReason: 'completed', endedAt: BASE_MS + 1,
437
+ turns: 1, totalTokens: 10, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' },
438
+ },
439
+ })
440
+ }
2466
441
 
2467
- it('⑥ 自检行计数 = 本次实扫结果 + 去重根注记', async () => {
2468
- const agentDir = join(tmp, 'agent')
2469
- // 根扫描无缓存(doctor 缓存机已删,ext-simplify-04 U3)——doctor 首跑后落盘的
2470
- // 文件,find 自检行照实扫计数(原 PS-14「doctor 缓存 0 文件」形态结构性不存在)
2471
- await mkdir(join(agentDir, 'sessions'), { recursive: true })
2472
- await handleSessionRead({ action: 'doctor' }, { agentDir })
2473
- // 之后 session 落盘(main 2 + subagent 1)
2474
- await writeSession(ID1)
2475
- await writeSession(ID2)
2476
- await writeSession('019e6c96-aaaa-bbbb-cccc-000000000003', { subagent: true })
2477
-
2478
- const r = await handleSessionRead(
2479
- { action: 'find', query: 'zzz-no-hit-9q8x' },
2480
- { agentDir },
2481
- )
2482
- const text = r.content[0].text
2483
- expect(text).toContain(`${join(agentDir, 'sessions')}:2 文件`)
2484
- expect(text).toContain(`${resolveSessionStoreRoot(agentDir)}:1 文件`)
2485
- expect(text).toContain('候选集非空(共 3 文件)')
2486
-
2487
- // 完整信号包(live 与 default 同路径)→ 去重根注记,不重复计数
2488
- const deduped = await handleSessionRead(
2489
- { action: 'find', query: 'zzz-no-hit-9q8x' },
2490
- { agentDir, liveSessionDir: join(agentDir, 'sessions') },
2491
- )
2492
- expect(deduped.content[0].text).toContain('同路径,已去重')
2493
- expect(deduped.content[0].text).toContain('候选集非空(共 3 文件)')
2494
- })
2495
- })
2496
-
2497
- // ============================================================
2498
- // 片段解析接通 [live] 根(同一 roots 契约):resolveByFragment/doXxx 透传
2499
- // liveSessionDir——[live]≠[default] 环境(纯 pi + 显式 --session-dir)下,find
2500
- // 能列出的 session 在 outline/result 等解析路径同样可达;F1 自检行含 [live] 根行。
2501
- // 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
2502
- // ============================================================
2503
-
2504
- describe('片段解析接通 [live] 根(fixture,同一 roots 契约)', () => {
2505
- let tmp: string
2506
- const SLUG = '--live-cwd--'
2507
- const ID = '019e6c96-aaaa-bbbb-cccc-0000000000aa'
2508
- const FRAGMENT = '0000000000aa'
2509
- let agentDir: string
2510
- /** liveSessionDir 信号(encodeCwd 形态,normalizeLiveSessionDir 剥层到 sessions/ 根)。 */
2511
- let liveDir: string
2512
- /** 剥层后的 [live] 根路径(F1 自检行的显示形态)。 */
2513
- let liveRoot: string
2514
-
2515
- /** 在指定目录写 session 文件(header + user 消息,extraLines 追加 assistant 等条目)。 */
2516
- async function writeSessionAt(dir: string, id: string, extraLines: string[] = []): Promise<void> {
2517
- await mkdir(dir, { recursive: true })
2518
- const lines = [
2519
- JSON.stringify({ type: 'session', id, cwd: '/demo' }),
2520
- JSON.stringify({
2521
- type: 'message',
2522
- id: `${id}-m1`,
2523
- parentId: id,
2524
- message: { role: 'user', content: [{ type: 'text', text: 'live 根里的会话' }] },
2525
- }),
2526
- ...extraLines,
2527
- ]
2528
- await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
442
+ /**
443
+ * pi 形态的 subagent-record custom entry 行(回归 A1 instrument):engine 键缺省 +
444
+ * sessionRef 无 dbPath——subagent-core record-entry.ts 的 pi 派生视图形态(engineHandle
445
+ * 可选、sessionRef 整体透传不枚举内部键;最小可信子集,跨包漂移由 entry-anchor.test 检查)。
446
+ */
447
+ function piRecordLine(entryId: string, saId: string): string {
448
+ return JSON.stringify({
449
+ type: 'custom',
450
+ customType: 'subagent-record',
451
+ id: entryId,
452
+ parentId: null,
453
+ data: {
454
+ // 登记 §3.3:条目范围 = v2 族——pi 形态 = 注册条(身份)+ 终态条(engineHandle 半键)。
455
+ v: 2,
456
+ kind: 'settled',
457
+ id: saId,
458
+ status: 'idle',
459
+ stopReason: 'completed',
460
+ endedAt: BASE_MS + 1,
461
+ turns: 1,
462
+ totalTokens: 10,
463
+ engineHandle: { sessionRef: { sessionId: SESS_A }, poolKey: 'shared' },
464
+ },
465
+ })
2529
466
  }
2530
467
 
2531
- beforeEach(async () => {
2532
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-live-'))
2533
- agentDir = join(tmp, 'agent')
2534
- await mkdir(join(agentDir, 'sessions'), { recursive: true }) // [default] 空根
2535
- liveDir = join(tmp, 'pi-live', 'sessions', SLUG)
2536
- liveRoot = join(tmp, 'pi-live', 'sessions')
2537
- })
2538
- afterEach(async () => {
2539
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2540
- })
468
+ const HEADER_LINE = JSON.stringify({ type: 'session', id: 'main-session-1', cwd: '/proj' })
2541
469
 
2542
- it('① [live]≠[default]:outline 片段解析到 [live] 根 session;不传信号则 F1(负向对照)', async () => {
2543
- await writeSessionAt(liveDir, ID)
2544
- // 接通后:片段解析可达 [live] 根,outline 正常产出
2545
- const r = await handleSessionRead(
2546
- { action: 'outline', session: FRAGMENT },
2547
- { agentDir, liveSessionDir: liveDir },
2548
- )
2549
- const d = r.details as { stats: { totalTurns: number } }
2550
- expect(d.stats.totalTurns).toBeGreaterThanOrEqual(1)
2551
- // 负向对照:不传 liveSessionDir(三根降级)→ 同一片段 F1 零匹配
2552
- //([default]/[legacy]/[subagent] 均无此文件——证明命中确实来自 [live] 根)
2553
- await expect(
2554
- handleSessionRead({ action: 'outline', session: FRAGMENT }, { agentDir }),
2555
- ).rejects.toThrow(/无匹配 session/)
470
+ const USER_LINE = JSON.stringify({
471
+ type: 'message',
472
+ id: 'u1',
473
+ parentId: null,
474
+ message: { role: 'user', content: [{ type: 'text', text: 'dispatch subagent' }] },
2556
475
  })
2557
476
 
2558
- it('② F1 自检行含 [live] 根行(剥层路径 + 实扫计数),与 [default] 空根同表可见', async () => {
2559
- await writeSessionAt(liveDir, ID)
2560
- const caught = await handleSessionRead(
2561
- { action: 'outline', session: 'zzz-no-hit-9q8x' },
2562
- { agentDir, liveSessionDir: liveDir },
2563
- ).catch((e: unknown) => e)
2564
- expect(caught).toBeInstanceOf(Error)
2565
- const text = (caught as Error).message
2566
- expect(text).toContain('无匹配 session:"zzz-no-hit-9q8x"')
2567
- expect(text).toContain('自检(发现层,只陈述事实)')
2568
- // [live] 根行:encodeCwd 已剥层到 sessions/ 根,计数 = 本次实扫
2569
- expect(text).toContain(`${liveRoot}:1 文件`)
2570
- // [default] 空根同表可见(同一 roots 契约的全貌)
2571
- expect(text).toContain(`${join(agentDir, 'sessions')}:0 文件`)
2572
- })
477
+ /** liveSessionDir 内的主 session 文件(entry 兜底候选)。 */
478
+ function writeLiveMainSession(lines: string[]): void {
479
+ writeFileSync(join(liveDir, 'main-session.jsonl'), [HEADER_LINE, USER_LINE, ...lines].join('\n') + '\n', 'utf8')
480
+ }
2573
481
 
2574
- it('③ result 片段形态同样可达 [live] 根(per-call deps 接线)', async () => {
2575
- await writeSessionAt(liveDir, ID, [
2576
- JSON.stringify({
2577
- type: 'message',
2578
- id: `${ID}-a1`,
2579
- parentId: `${ID}-m1`,
2580
- message: { role: 'assistant', content: [{ type: 'text', text: '最终结论正文' }] },
2581
- }),
2582
- ])
2583
- const r = await handleSessionRead(
2584
- { action: 'result', session: FRAGMENT },
2585
- { agentDir, liveSessionDir: liveDir },
482
+ /** main 默认根的 root session(family/rootSessionId 视图的 byId 素材)。 */
483
+ function writeRootSessionFile(): void {
484
+ const sessionsDir = join(agentDir, 'sessions')
485
+ mkdirSync(sessionsDir, { recursive: true })
486
+ writeFileSync(
487
+ join(sessionsDir, `1700000000000_${ROOT_SESSION}.jsonl`),
488
+ JSON.stringify({ type: 'session', id: ROOT_SESSION, cwd: '/proj' }) + '\n',
489
+ 'utf8',
2586
490
  )
2587
- expect(r.content[0].text).toContain('最终结论正文')
2588
- })
2589
- })
2590
-
2591
- // ============================================================
2592
- // u10:find 分组输出(design 2026-09-10 §5.1 形态 / §6.7 子决策 2/3 / §8.2 回归基线)
2593
- // ============================================================
2594
-
2595
- describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归基线)', () => {
2596
- let tmp: string
2597
- const SLUG = '--demo-cwd--'
2598
- // uuidv7 同毫秒时间前缀碰撞是现网「34 条 subagent」形态的成因(§3.4),fixture 沿用共享前缀
2599
- const PREFIX = '019e6c96'
2600
- const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
2601
- const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
2602
-
2603
- /** 写 session 文件(subagent:true 写入 subagent 根),形态同 u9 段 writeSession。 */
2604
- async function writeSession(
2605
- id: string,
2606
- opts?: { subagent?: boolean; firstUserText?: string },
2607
- ): Promise<void> {
2608
- // [session-store-layout-inversion] subagent 会话在新布局存储树(分区 sid 固定合法形态)
2609
- const dir = opts?.subagent
2610
- ? resolveStoreSubagentSessionsDir(join(tmp, 'agent'), '0aaaaaaa-bbbb-7ccc-dddd-0000000000s1')
2611
- : join(tmp, 'agent', 'sessions', SLUG)
2612
- await mkdir(dir, { recursive: true })
2613
- const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
2614
- if (opts?.firstUserText !== undefined) {
2615
- lines.push(
2616
- JSON.stringify({
2617
- type: 'message',
2618
- id: `${id}-m1`,
2619
- message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
2620
- }),
2621
- )
2622
- }
2623
- await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
2624
491
  }
2625
492
 
2626
- /**
2627
- * 写带 assistant 正文的 subagent(manifest + session 文件三行形态),
2628
- * 供 result action 批量调用(回归基线:批量头行 8 字符短 id 不受 u10 影响)。
2629
- */
2630
- async function writeSubagentWithResult(saId: string, id: string, body: string): Promise<void> {
2631
- const sessionFile = join(
2632
- resolveStoreSubagentSessionsDir(join(tmp, 'agent'), 'root-u10'),
2633
- `${id}.jsonl`,
493
+ /** pi manifest + pi subagent session 文件(pi 穿插断言用;engine 缺省 = pi 存量形态)。 */
494
+ function writePiFixture(): void {
495
+ const subDir = join(dirname(agentDir), 'ext-sessions', ROOT_SESSION, 'subagents', 'sessions')
496
+ mkdirSync(subDir, { recursive: true })
497
+ const piSession = join(subDir, '019e6c96-pi-sub-agent-00000000004a4.jsonl')
498
+ writeFileSync(
499
+ piSession,
500
+ [
501
+ JSON.stringify({ type: 'session', id: '019e6c96-pi-sub-agent-00000000004a4', cwd: '/proj' }),
502
+ JSON.stringify({ type: 'message', id: 'p1', parentId: null, message: { role: 'user', content: [{ type: 'text', text: 'pi task' }] } }),
503
+ JSON.stringify({ type: 'message', id: 'p2', parentId: 'p1', message: { role: 'assistant', content: [{ type: 'text', text: 'PI-SENTINEL 正文内容' }] } }),
504
+ ].join('\n') + '\n',
505
+ 'utf8',
2634
506
  )
2635
- await mkdir(dirname(sessionFile), { recursive: true })
2636
- const lines = [
2637
- JSON.stringify({ type: 'session', id, cwd: '/demo' }),
507
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', ROOT_SESSION, 'subagents', 'records')
508
+ mkdirSync(recordsDir, { recursive: true })
509
+ writeFileSync(
510
+ join(recordsDir, 'sa-pi-1.json'),
2638
511
  JSON.stringify({
2639
- type: 'message',
2640
- id: `${id}-m1`,
2641
- message: { role: 'user', content: [{ type: 'text', text: 'task' }] },
512
+ id: 'sa-pi-1',
513
+ rootSessionId: ROOT_SESSION,
514
+ agentName: 'pi-agent',
515
+ sessionFile: piSession,
516
+ slug: 'pi-sub',
517
+ status: 'completed',
2642
518
  }),
2643
- JSON.stringify({
2644
- type: 'message',
2645
- id: `${id}-a1`,
2646
- message: { role: 'assistant', content: [{ type: 'text', text: body }] },
2647
- }),
2648
- ]
2649
- await writeFile(sessionFile, lines.join('\n') + '\n')
2650
- const recordsDir = resolveStoreSubagentRecordsDir(join(tmp, 'agent'), 'root-u10')
2651
- await mkdir(recordsDir, { recursive: true })
2652
- await writeFile(
2653
- join(recordsDir, `${saId}.json`),
2654
- JSON.stringify({ id: saId, rootSessionId: 'root-u10', agentName: 'explorer', sessionFile }),
519
+ 'utf8',
2655
520
  )
2656
521
  }
2657
522
 
2658
- function find(
2659
- query: string,
2660
- extra?: Partial<SessionReadParams>,
2661
- ): ReturnType<typeof handleSessionRead> {
2662
- return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
2663
- }
523
+ const signals = () => ({ agentDir, liveSessionDir: liveDir })
2664
524
 
2665
- interface FindDetails {
2666
- matches: Array<{ source: string; sessionId: string }>
2667
- truncated: boolean
525
+ function logCallsWithReason(reason: string): number {
526
+ return appendEntries.mock.calls.filter(
527
+ ([ct, d]) => ct === 'session-reader:log' && (d as { data?: { reason?: string } })?.data?.reason === reason,
528
+ ).length
2668
529
  }
2669
530
 
2670
- beforeEach(async () => {
2671
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-u10-'))
2672
- })
2673
- afterEach(async () => {
2674
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2675
- })
531
+ // ---------------------------------------------------------------------------
532
+ // 变体 M(manifest 在场,主路径)+ 场景 2 动作覆盖
533
+ // ---------------------------------------------------------------------------
2676
534
 
2677
- it('① main 置顶 + 全 id + ↳ 可复制调用串 + 编号跨组连续(§5.1 全列出形态)', async () => {
2678
- const M1 = mainId(1)
2679
- const S1 = subId(1)
2680
- const S2 = subId(2)
2681
- await writeSession(M1, { firstUserText: 'main content' })
2682
- await writeSession(S1, { subagent: true, firstUserText: 'sub content one' })
2683
- await writeSession(S2, { subagent: true, firstUserText: 'sub content two' })
2684
-
2685
- const r = await find(PREFIX)
2686
- const text = r.content[0].text
2687
- // main 段置顶:main 组头与 main id 均先于 subagent 出现
2688
- expect(text.indexOf('main(')).toBeLessThan(text.indexOf('subagent('))
2689
- expect(text.indexOf(M1)).toBeLessThan(text.indexOf(S1))
2690
- expect(text).toContain('main(1 条命中):')
2691
- expect(text).toContain('subagent(2 条命中):')
2692
- // 全 id:36 字符完整出现,8 字符截断形态消失(§6.7 子决策 3)
2693
- expect(text).toContain(M1)
2694
- expect(text).not.toContain(`${M1.slice(0, 8)}…`)
2695
- // ↳ 可复制调用串只附 main 候选(subagent 噪声不配指针)
2696
- expect(text).toContain(`↳ session_read { action:"outline", session:"${M1}" }`)
2697
- expect(text).not.toContain(`session:"${S1}"`)
2698
- // 编号跨组连续:main 是 1 号,subagent 段续 2 号(组内 mtime 序不固定,用正则)
2699
- expect(text).toContain(` 1. ${M1}`)
2700
- expect(text).toMatch(/ 2\. 019e6c96-aaaa-bbbb-cccc-e0000000000[12]/)
2701
- // details 合并列表:main 首位、集合完整、无截断
2702
- const d = r.details as FindDetails
2703
- expect(d.truncated).toBe(false)
2704
- expect(d.matches[0]).toMatchObject({ source: 'main', sessionId: M1 })
2705
- expect(new Set(d.matches.map((m) => m.sessionId))).toEqual(new Set([M1, S1, S2]))
2706
- })
535
+ describe('变体 M:manifest 主路径(instrument:manifest 锚与 entry 锚不同 session)', () => {
536
+ beforeEach(() => {
537
+ createZcodeDb(dbPath, '0.16.5', [sessARows(), sessBRows()])
538
+ // manifest 锚 → sess_bbb;liveSessionDir entry 锚 → sess_aaa:读到 ZBSENTINEL
539
+ // 而非 ZASENTINEL = 走了 manifest 主路径(entry 兜底未被进入的输出级证据)
540
+ writeZcodeManifest(SA_M)
541
+ writeLiveMainSession([recordLine('e1', SA_M, { sessionId: SESS_A, dbPath })])
542
+ writeRootSessionFile()
543
+ })
2707
544
 
2708
- it('② subagent 超剩余配额 → 折叠计数行 + 加 source:"subagent" 提示(§6.7 子决策 2)', async () => {
2709
- const M1 = mainId(1)
2710
- await writeSession(M1)
2711
- for (let i = 1; i <= 3; i++) await writeSession(subId(i), { subagent: true })
2712
-
2713
- // limit 2:main 1 条(无溢出)→ 剩余配额 1 → subagent 展示 1 条 + 溢出折叠
2714
- const r = await find(PREFIX, { limit: 2 })
2715
- const text = r.content[0].text
2716
- expect(text).toContain('main(1 条命中):')
2717
- expect(text).toContain('subagent(>1 条命中,显示前 1 条):')
2718
- expect(text).toContain('… 另有 subagent 命中未显示。👉 加 source:"subagent" 查看')
2719
- const d = r.details as FindDetails
2720
- expect(d.truncated).toBe(true)
2721
- expect(d.matches).toHaveLength(2)
2722
- expect(d.matches[0].source).toBe('main')
2723
- expect(d.matches.filter((m) => m.source === 'subagent')).toHaveLength(1)
2724
- })
545
+ it('outline:读到 manifest 锚的 sess_bbb(ZBSENTINEL 在,ZASENTINEL 不在),两轮 T001/T002', async () => {
546
+ const r = await handleSessionRead({ action: 'outline', session: SA_M }, signals())
547
+ const text = r.content[0]?.text ?? ''
548
+ expect(text).toContain('ZBSENTINEL')
549
+ expect(text).not.toContain('ZASENTINEL')
550
+ // D9② 占位行折叠:T000 = converter 注入的 session_info 前置条目(纯记录,非对话),
551
+ // 折叠后不产行;对话轮保留原 turn.index(T001/T002),totalTurns 仍 = 2
552
+ expect(text).toContain('T001')
553
+ expect(text).toContain('T002')
554
+ expect(text).not.toMatch(/\bT000\b/)
555
+ // outline 的 isCompaction 恒 false 属已登记形态(§4 已接受代价)——无独立压缩 turn
556
+ expect(text).not.toContain('compaction')
557
+ })
2725
558
 
2726
- it('③ main 命中占满 limit → subagent 段 0 条仅显示计数(§6.7:main 优先占满)', async () => {
2727
- for (let i = 1; i <= 3; i++) await writeSession(mainId(i))
2728
- for (let i = 1; i <= 2; i++) await writeSession(subId(i), { subagent: true })
2729
-
2730
- const r = await find(PREFIX, { limit: 3 })
2731
- const text = r.content[0].text
2732
- expect(text).toContain('main(3 条命中):')
2733
- expect(text).toContain('subagent(有命中未显示——展示配额已被 main 占满):')
2734
- expect(text).toContain('👉 加 source:"subagent" 查看')
2735
- // subagent 条目与调用串不出现(0 条展示)
2736
- expect(text).not.toContain(subId(1))
2737
- const d = r.details as FindDetails
2738
- expect(d.matches).toHaveLength(3)
2739
- expect(d.matches.every((m) => m.source === 'main')).toBe(true)
2740
- // 合并总量:命中 3 main + 2 sub > 展示 3 → truncated=true
2741
- expect(d.truncated).toBe(true)
2742
- })
559
+ it('result:单 id 纯正文 = sess_bbb 最终 assistant 文本(末轮,非仅通知本轮)', async () => {
560
+ const r = await handleSessionRead({ action: 'result', session: SA_M }, signals())
561
+ expect(r.content[0]?.text).toBe('第二轮 ZBSENTINEL 收尾')
562
+ })
2743
563
 
2744
- it('④ truncated 按合并总量计算(命中总数 vs 实际输出数,含 main 恰满边界)', async () => {
2745
- for (let i = 1; i <= 2; i++) await writeSession(mainId(i))
2746
- for (let i = 1; i <= 2; i++) await writeSession(subId(i), { subagent: true })
2747
-
2748
- // a) 全列出(4 ≤ 20):truncated=false
2749
- const all = (await find(PREFIX)).details as FindDetails
2750
- expect(all.matches).toHaveLength(4)
2751
- expect(all.truncated).toBe(false)
2752
-
2753
- // b) main 恰好占满 limit(2/2)+ subagent 有命中 → 仍截断(防 main 恰满漏探测 subagent)
2754
- const exact = (await find(PREFIX, { limit: 2 })).details as FindDetails
2755
- expect(exact.matches).toHaveLength(2)
2756
- expect(exact.matches.every((m) => m.source === 'main')).toBe(true)
2757
- expect(exact.truncated).toBe(true)
2758
-
2759
- // c) 混合截断:展示 3(2 main + 1 sub)< 命中 4 → truncated=true
2760
- const mixed = (await find(PREFIX, { limit: 3 })).details as FindDetails
2761
- expect(mixed.matches).toHaveLength(3)
2762
- expect(mixed.matches.filter((m) => m.source === 'main')).toHaveLength(2)
2763
- expect(mixed.matches.filter((m) => m.source === 'subagent')).toHaveLength(1)
2764
- expect(mixed.truncated).toBe(true)
2765
- })
564
+ it('result 截断指针:zcode 路由抑制 read 半边(库路径二进制不可 read),只留 session_read detail 可执行半边', async () => {
565
+ const r = await handleSessionRead({ action: 'result', session: SA_M, limit: 5 }, signals())
566
+ const text = r.content[0]?.text ?? ''
567
+ expect(text).toContain('[truncated 5 of')
568
+ // 可执行半边在场(错误 → 权威源 → 重试闭环)
569
+ expect(text).toContain(`session_read { action:"detail", session:"${SA_M}" }`)
570
+ // read 半边被抑制:不指库路径,也不出现 "full text: read" 形态
571
+ expect(text).not.toContain(dbPath)
572
+ expect(text).not.toContain('full text: read')
573
+ })
574
+
575
+ it('detail:toolCall 全配对(bash)+ compaction custom entry 形态(渲染现状锚定)', async () => {
576
+ const r = await handleSessionRead({ action: 'detail', session: SA_M, turns: 'T000-T002' }, signals())
577
+ const text = r.content[0]?.text ?? ''
578
+ expect(text).toContain('ZZQFIXTURE')
579
+ expect(text).toContain('bash')
580
+ expect(text).toContain('[custom:zcode-import:compaction]')
581
+ // A6 断言强化①:compaction custom entry 计数等式(fixture 恰 1 个压缩点)——
582
+ // 防「既不多」方向无防护(多插压缩点/重复渲染的实现在此失败)
583
+ expect((text.match(/\[custom:zcode-import:compaction\]/g) ?? []).length).toBe(1)
584
+ // A6 断言强化②:toolCall/toolResult 按 toolCallId 全配对(全文态 entries 直读——
585
+ // 摘要态 ToolResultSummaryEntry 不携带 toolCallId,无法配对)。converter 契约
586
+ // 「未完成 tool 整对丢弃防 dangling」在此以集合配对相等 + 双向 0 dangling 锚定。
587
+ const full = await handleSessionRead(
588
+ { action: 'detail', session: SA_M, turns: 'T000-T002', includeToolResult: true },
589
+ signals(),
590
+ )
591
+ const entries = (full.details as { entries: Array<Record<string, unknown>> }).entries
592
+ const callIds = new Set<string>()
593
+ const resultIds = new Set<string>()
594
+ for (const e of entries) {
595
+ const msg = e.message as { role?: unknown; content?: unknown; toolCallId?: unknown } | undefined
596
+ if (msg === undefined) continue
597
+ if (Array.isArray(msg.content)) {
598
+ for (const b of msg.content) {
599
+ if (b && typeof b === 'object' && (b as Record<string, unknown>).type === 'toolCall') {
600
+ const id = (b as Record<string, unknown>).id
601
+ if (typeof id === 'string') callIds.add(id)
602
+ }
603
+ }
604
+ }
605
+ if (msg.role === 'toolResult' && typeof msg.toolCallId === 'string') {
606
+ resultIds.add(msg.toolCallId)
607
+ }
608
+ }
609
+ expect(resultIds).toEqual(callIds) // 配对相等 = 无 toolCall 缺 result、无 result 悬空
610
+ expect(callIds.size).toBe(1) // fixture 恰 1 对(tc-1)——非平凡性锚(空集恒配对不构成证明)
611
+ })
2766
612
 
2767
- it('⑤a 回归基线:find{query:<前缀>, limit:100} main 0 条 + subagent 全列(现网 34 条形态)', async () => {
2768
- for (let i = 1; i <= 34; i++) await writeSession(subId(i), { subagent: true })
2769
-
2770
- const r = await find(PREFIX, { limit: 100 })
2771
- const d = r.details as FindDetails
2772
- expect(d.matches).toHaveLength(34)
2773
- expect(d.matches.every((m) => m.source === 'subagent')).toBe(true)
2774
- expect(d.truncated).toBe(false)
2775
- const text = r.content[0].text
2776
- // main 段 0 条可辨识(防「全是 subagent」被误读),subagent 段全列不折叠
2777
- expect(text).toContain('main(0 条命中):')
2778
- expect(text).toContain('subagent(34 条命中):')
2779
- expect(text).not.toContain('加 source:"subagent"')
613
+ it('search:ZZQFIXTURE 命中(canonical entries 走既有检索管线)', async () => {
614
+ const r = await handleSessionRead({ action: 'search', session: SA_M, pattern: 'ZZQFIXTURE' }, signals())
615
+ const text = r.content[0]?.text ?? ''
616
+ expect(text).toContain('ZZQFIXTURE')
617
+ expect(text).toContain('1 hit')
618
+ })
2780
619
  })
2781
620
 
2782
- it('⑤b 回归基线:result 批量头行仍 8 字符短 id(SESSION_ID_PREFIX_LEN 通路不动)', async () => {
2783
- const R1 = `${PREFIX}-aaaa-bbbb-cccc-f00000000001`
2784
- const R2 = `${PREFIX}-aaaa-bbbb-cccc-f00000000002`
2785
- await writeSubagentWithResult('sa-u10-r1', R1, 'alpha body')
2786
- await writeSubagentWithResult('sa-u10-r2', R2, 'beta body')
621
+ // ---------------------------------------------------------------------------
622
+ // 变体 E(manifest 缺位 → entry 兜底取末条)
623
+ // ---------------------------------------------------------------------------
2787
624
 
2788
- const res = await handleSessionRead(
2789
- { action: 'result', session: 'sa-u10-r1,sa-u10-r2' },
2790
- { agentDir: join(tmp, 'agent') },
2791
- )
2792
- const text = res.content[0].text
2793
- expect(text).toContain(`(session ${R1.slice(0, 8)}…)`)
2794
- expect(text).toContain(`(session ${R2.slice(0, 8)}…)`)
2795
- })
625
+ describe('变体 E:manifest 缺位 → entry 兜底(liveSessionDir 内取文件顺序末条)', () => {
626
+ beforeEach(() => {
627
+ createZcodeDb(dbPath, '0.16.5', [sessARows(), sessBRows()])
628
+ // ≥2 条同 sa-id entry:首条 sess_aaa、末条 sess_bbb(F16 每轮覆写)——
629
+ // 取首条/合并的实现会输出 sess_aaa 行集,必失败
630
+ writeLiveMainSession([
631
+ recordLine('e1', SA_E, { sessionId: SESS_A, dbPath }),
632
+ recordLine('e2', SA_E, { sessionId: SESS_B, dbPath }),
633
+ ])
634
+ })
2796
635
 
2797
- it('⑤c 回归基线:recent 查询 main 段置顶可辨识', async () => {
2798
- for (let i = 1; i <= 2; i++) await writeSession(mainId(i))
2799
- for (let i = 1; i <= 2; i++) await writeSession(subId(i), { subagent: true })
2800
-
2801
- const r = await find('recent')
2802
- const text = r.content[0].text
2803
- expect(text.indexOf('main(2 条命中):')).toBeLessThan(text.indexOf('subagent(2 条命中):'))
2804
- const d = r.details as FindDetails
2805
- expect(d.matches).toHaveLength(4)
2806
- // 分组价值:main/subagent 的 mtime 交错也被 main 段置顶(§8.2「main 段置顶后可辨识」)
2807
- expect(d.matches.slice(0, 2).every((m) => m.source === 'main')).toBe(true)
2808
- expect(d.truncated).toBe(false)
2809
- })
636
+ it('result:兜底命中末条 → sess_bbb 正文(ZBSENTINEL 在,ZASENTINEL 不在)', async () => {
637
+ const r = await handleSessionRead({ action: 'result', session: SA_E }, signals())
638
+ expect(r.content[0]?.text).toBe('第二轮 ZBSENTINEL 收尾')
639
+ expect(r.content[0]?.text).not.toContain('ZASENTINEL')
640
+ })
2810
641
 
2811
- it('⑥ resolveByFragment 独立无分组语义:outline 片段多匹配 → F2 全量候选(不被配额截断)', async () => {
2812
- for (let i = 1; i <= 2; i++) await writeSession(mainId(i))
2813
- for (let i = 1; i <= 3; i++) await writeSession(subId(i), { subagent: true })
2814
-
2815
- // 解析路径(resolveByFragment → findSessions limit:10 无 source)保持 mtime 排序 +
2816
- // limit 截断原语义:5 命中全进 F2 消歧候选,无 main 置顶/配额分组介入(§6.7 末条)
2817
- const r = await handleSessionRead({ action: 'outline', session: PREFIX }, { agentDir: join(tmp, 'agent') })
2818
- const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
2819
- expect(d.ambiguous).toBe(true)
2820
- expect(d.candidates).toHaveLength(5)
2821
- expect(new Set(d.candidates.map((c) => c.sessionId))).toEqual(
2822
- new Set([mainId(1), mainId(2), subId(1), subId(2), subId(3)]),
2823
- )
642
+ it('兜底越界:liveSessionDir 内无该 sa-id 的任何 entry → zcode_record_not_found + 指引', async () => {
643
+ await expect(
644
+ handleSessionRead({ action: 'result', session: 'sa-nowhere-1' }, signals()),
645
+ ).rejects.toThrow(/zcode_record_not_found[\s\S]*👉/)
646
+ })
2824
647
  })
2825
648
 
2826
- it('⑥b 显式 source 查询走单组原语义:不折叠、保持 mtime+limit 截断', async () => {
2827
- for (let i = 1; i <= 3; i++) await writeSession(subId(i), { subagent: true })
2828
-
2829
- // 显式 source 本身就是折叠提示所指的展开动作,不再折叠
2830
- const r = await find(PREFIX, { source: 'subagent', limit: 1 })
2831
- const text = r.content[0].text
2832
- expect(text).toContain('subagent(1 条命中):')
2833
- expect(text).not.toContain('加 source:"subagent"')
2834
- const d = r.details as FindDetails
2835
- expect(d.matches).toHaveLength(1)
2836
- expect(d.matches[0].source).toBe('subagent')
2837
- expect(d.truncated).toBe(true)
2838
- })
2839
- })
2840
-
2841
- // ============================================================
2842
- // E1:find 单次根解析(ext-simplify-04 U2,探针 P1)。spy 打在 roots 模块命名空间
2843
- // 上跨模块拦截(find.ts / findNoMatch 渲染链的消费点全走同一入口),断言 doFind
2844
- // 全路径(分组 / 显式 source / 零匹配)resolveSessionRoots 恰调用 1 次——改前分组
2845
- // 2 次、零匹配 3 次(impl-plan D-15④ 登记的分组双倍实扫债),预解析根复用后恒 1 次。
2846
- // ============================================================
2847
- describe('E1 find 单次根解析(探针 P1:resolveSessionRoots 恰 1 次)', () => {
2848
- let tmp: string
2849
- const SLUG = '--e1-cwd--'
2850
- const PREFIX = '019e7e10'
2851
- const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
2852
- const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
2853
-
2854
- /** 写 session 文件(subagent:true 写入 subagent 根),形态同 u10 段 writeSession。 */
2855
- async function writeSession(id: string, opts?: { subagent?: boolean }): Promise<void> {
2856
- // [session-store-layout-inversion] subagent 会话在新布局存储树(分区 sid 固定合法形态)
2857
- const dir = opts?.subagent
2858
- ? resolveStoreSubagentSessionsDir(join(tmp, 'agent'), '0aaaaaaa-bbbb-7ccc-dddd-0000000000s1')
2859
- : join(tmp, 'agent', 'sessions', SLUG)
2860
- await mkdir(dir, { recursive: true })
2861
- await writeFile(
2862
- join(dir, `${id}.jsonl`),
2863
- JSON.stringify({ type: 'session', id, cwd: '/e1' }) + '\n',
2864
- )
2865
- }
649
+ // ---------------------------------------------------------------------------
650
+ // 场景 5:八类失败逐一断言(错误码 + 👉 指引 + ④ 顺序断言两种构造)
651
+ // ---------------------------------------------------------------------------
2866
652
 
2867
- function find(
2868
- query: string,
2869
- extra?: Partial<SessionReadParams>,
2870
- ): ReturnType<typeof handleSessionRead> {
2871
- return handleSessionRead({ action: 'find', query, ...extra }, { agentDir: join(tmp, 'agent') })
2872
- }
653
+ describe('场景 5 八类失败(§3.4 表逐行 + 检查顺序三段递进)', () => {
654
+ it('① manifest engine=zcode 但 engineHandle 整体缺席 → zcode_anchor_missing(导入对话框指引)', async () => {
655
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
656
+ writeZcodeManifest(SA_M, { omitEngineHandle: true })
657
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
658
+ /zcode_anchor_missing[\s\S]*导入/,
659
+ )
660
+ })
2873
661
 
2874
- /** spy 计数器 + 断言 helper:包装一段 find 调用,返回 { 结果, 调用次数 }。 */
2875
- async function findCounting(
2876
- query: string,
2877
- extra?: Partial<SessionReadParams>,
2878
- ): Promise<{ result: Awaited<ReturnType<typeof handleSessionRead>>; resolveCalls: number }> {
2879
- const rootsModule = await import('../discovery/roots.js')
2880
- const spy = vi.spyOn(rootsModule, 'resolveSessionRoots')
2881
- try {
2882
- const result = await find(query, extra)
2883
- return { result, resolveCalls: spy.mock.calls.length }
2884
- } finally {
2885
- spy.mockRestore()
2886
- }
2887
- }
662
+ it('② entry 的 sessionRef 只留 sessionId、删 dbPath → zcode_anchor_missing + 结构化日志记「缺 dbPath」', async () => {
663
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
664
+ writeLiveMainSession([recordLine('e1', SA_M, { sessionId: SESS_B })])
665
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
666
+ 'zcode_anchor_missing',
667
+ )
668
+ // 归因进结构化日志不进 LLM 可见部分(§3.4):缺失键名可区分(missing-dbPath)
669
+ expect(logCallsWithReason('missing-dbPath')).toBeGreaterThanOrEqual(1)
670
+ })
2888
671
 
2889
- beforeEach(async () => {
2890
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-e1-'))
2891
- })
2892
- afterEach(async () => {
2893
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2894
- })
672
+ it('③ manifest 删除 + liveSessionDir 内无该 sa-id entry → zcode_record_not_found(三动作指引)', async () => {
673
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
674
+ writeLiveMainSession([recordLine('e1', 'sa-other-1', { sessionId: SESS_B, dbPath })])
675
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
676
+ /zcode_record_not_found[\s\S]*派发的那个会话[\s\S]*family[\s\S]*换一个已完成/,
677
+ )
678
+ })
2895
679
 
2896
- it('① 分组路径(main + subagent 两段查询,有命中):恰 1 次', async () => {
2897
- await writeSession(mainId(1))
2898
- await writeSession(subId(1), { subagent: true })
2899
- const { result, resolveCalls } = await findCounting(PREFIX)
2900
- const d = result.details as { matches: Array<{ source: string }>; truncated: boolean }
2901
- // 前置:两段确实各自命中(分组语义不变),再断言扫描收敛
2902
- expect(d.matches).toHaveLength(2)
2903
- expect(new Set(d.matches.map((m) => m.source))).toEqual(new Set(['main', 'subagent']))
2904
- expect(d.truncated).toBe(false)
2905
- expect(resolveCalls).toBe(1)
2906
- })
680
+ it('③b pi 形态 entry(engine 缺省、sessionRef 无 dbPath)在场 + manifest 缺位 → zcode_record_not_found(engine 判别防误归因 anchor_missing)', async () => {
681
+ // 回归 A1:pi record register() 先于 manifest settle 的窗口——manifest 缺位 +
682
+ // entry 为 pi 形态。pi record 不是「旧版本 zcode 产物」(导入对话框指引不适用),
683
+ // 必须落 zcode_record_not_found;误归因 missing-dbPath → zcode_anchor_missing 即红。
684
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
685
+ writeLiveMainSession([piRecordLine('e-pi-1', SA_M)])
686
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
687
+ /zcode_record_not_found/,
688
+ )
689
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.not.toThrow(
690
+ /zcode_anchor_missing/,
691
+ )
692
+ })
2907
693
 
2908
- it('② 显式 source 路径(单组查询,有命中):恰 1 次', async () => {
2909
- await writeSession(mainId(1))
2910
- await writeSession(subId(1), { subagent: true })
2911
- const { result, resolveCalls } = await findCounting(PREFIX, { source: 'subagent' })
2912
- const d = result.details as { matches: Array<{ source: string }> }
2913
- expect(d.matches).toHaveLength(1)
2914
- expect(d.matches[0].source).toBe('subagent')
2915
- expect(resolveCalls).toBe(1)
2916
- })
694
+ it('④a 顺序断言(构造一):dbPath 集合外且文件真实存在 → zcode_db_path_forbidden', async () => {
695
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
696
+ const evilPath = join(root, 'evil-whitelist-probe.sqlite')
697
+ writeFileSync(evilPath, 'not a real db', 'utf8') // 存在性为真 → 走到路径段
698
+ writeZcodeManifest(SA_M, { sessionRef: { sessionId: SESS_B, dbPath: evilPath } })
699
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
700
+ /zcode_db_path_forbidden[\s\S]*👉/,
701
+ )
702
+ expect(existsSync(evilPath)).toBe(true)
703
+ })
2917
704
 
2918
- it('③ 分组零匹配 → F1 自检行:恰 1 次(改前 3 次)且根表仍进渲染', async () => {
2919
- await writeSession(mainId(1))
2920
- const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x')
2921
- const d = result.details as { matches: unknown[]; truncated: boolean }
2922
- expect(d.matches).toEqual([])
2923
- expect(d.truncated).toBe(false)
2924
- // F1 自检行消费预解析根表(非空根计数可见 = roots 确实传入而非空列表)
2925
- expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
2926
- expect(result.content[0].text).toContain('候选集非空')
2927
- expect(resolveCalls).toBe(1)
2928
- })
705
+ it('④b 顺序断言(构造二):同一集合外路径但文件不存在 → zcode_db_unreadable(存在性先于路径)', async () => {
706
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
707
+ const missingEvil = join(root, 'evil-whitelist-probe-missing.sqlite')
708
+ writeZcodeManifest(SA_M, { sessionRef: { sessionId: SESS_B, dbPath: missingEvil } })
709
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
710
+ /zcode_db_unreadable[\s\S]*👉/,
711
+ )
712
+ })
2929
713
 
2930
- it('④ 显式 source 零匹配 → F1 自检行:恰 1 次(改前 2 次)', async () => {
2931
- await writeSession(subId(1), { subagent: true })
2932
- const { result, resolveCalls } = await findCounting('zzz-e1-no-hit-9q8x', { source: 'main' })
2933
- expect(result.content[0].text).toContain('无匹配 session')
2934
- expect(result.content[0].text).toContain('自检(发现层,只陈述事实)')
2935
- expect(resolveCalls).toBe(1)
2936
- })
2937
- })
2938
-
2939
- // ============================================================
2940
- // u11:metadataProvider 注入 + TTL 缓存(design 2026-09-10 §6.6)
2941
- // handler 级集成:注入包装(metadata 缓存独立实例——根扫描无缓存,doctor 缓存机
2942
- // 已删除,ext-simplify-04 U3,不存在串扰面)
2943
- // + 标题渲染(§5.1 候选行标题优先)+ provider 抛错降级不外抛。
2944
- // 策略①惰性/②窄化/合并语义在 find.test.ts u11 段覆盖。
2945
- // ============================================================
2946
- describe('u11 metadataProvider 注入 + TTL 缓存(fixture,§6.6)', () => {
2947
- let tmp: string
2948
- /** 平铺 main 根(taiji 形态),同时作 liveSessionDir(与 [default] 根同字面路径) */
2949
- let flatMain: string
2950
-
2951
- async function writeSession(
2952
- id: string,
2953
- opts?: { firstUserText?: string },
2954
- ): Promise<string> {
2955
- await mkdir(flatMain, { recursive: true })
2956
- const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
2957
- if (opts?.firstUserText !== undefined) {
2958
- lines.push(
2959
- JSON.stringify({
2960
- type: 'message',
2961
- id: `${id}-m1`,
2962
- message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
2963
- }),
714
+ it('⑤ 白名单形态路径但库文件缺席 → zcode_db_unreadable(含 -wal 告诫指引)', async () => {
715
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
716
+ const missingInLayout = join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1), 'missing.sqlite')
717
+ writeZcodeManifest(SA_M, { sessionRef: { sessionId: SESS_B, dbPath: missingInLayout } })
718
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
719
+ /zcode_db_unreadable[\s\S]*👉[\s\S]*-wal/,
2964
720
  )
2965
- }
2966
- const path = join(flatMain, `${id}.jsonl`)
2967
- await writeFile(path, lines.join('\n') + '\n')
2968
- return path
2969
- }
721
+ })
2970
722
 
2971
- function findWithProvider(
2972
- query: string,
2973
- provider: SessionMetadataProvider,
2974
- ): ReturnType<typeof handleSessionRead> {
2975
- return handleSessionRead(
2976
- { action: 'find', query },
2977
- { agentDir: join(tmp, 'agent'), liveSessionDir: flatMain },
2978
- undefined,
2979
- provider,
2980
- )
2981
- }
723
+ it('⑥ schema_migration 版本超出已知集 → zcode_schema_drift(升级 taiji 指引)', async () => {
724
+ createZcodeDb(dbPath, '9.9.9', [sessBRows()])
725
+ writeZcodeManifest(SA_M)
726
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
727
+ /zcode_schema_drift[\s\S]*9\.9\.9[\s\S]*👉/,
728
+ )
729
+ })
2982
730
 
2983
- beforeEach(async () => {
2984
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-u11-'))
2985
- flatMain = join(tmp, 'agent', 'sessions')
2986
- })
2987
- afterEach(async () => {
2988
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
2989
- })
731
+ it('⑦ 传入 sess_ 形态 id → zcode_param_invalid(换完整 sa- id 指引)', async () => {
732
+ await expect(
733
+ handleSessionRead({ action: 'result', session: SESS_B }, signals()),
734
+ ).rejects.toThrow(/zcode_param_invalid[\s\S]*👉[\s\S]*sa-/)
735
+ })
2990
736
 
2991
- it('② 标题进匹配与渲染:keyword 命中 name → 候选行标题优先展示 + details.matches[].name 透传', async () => {
2992
- const id = 'm-title-0001'
2993
- const p = await writeSession(id, { firstUserText: '首消息无关' })
2994
- const provider = vi.fn(async (dir: string) =>
2995
- dir === flatMain
2996
- ? [
2997
- {
2998
- path: p,
2999
- id,
3000
- cwd: '/demo',
3001
- name: '太保寿险深度研究攻坚',
3002
- modified: new Date(),
3003
- firstMessage: '首消息无关',
3004
- },
3005
- ]
3006
- : [],
3007
- )
3008
- const r = await findWithProvider('太保寿险', provider)
3009
- const text = r.content[0].text
3010
- // §5.1 形态:候选行末段为标题(name 优先于首消息预览)
3011
- expect(text).toContain('太保寿险深度研究攻坚')
3012
- const d = r.details as { matches: Array<{ sessionId: string; name?: string }> }
3013
- expect(d.matches[0].sessionId).toBe(id)
3014
- expect(d.matches[0].name).toBe('太保寿险深度研究攻坚')
3015
- // 注入边界生效:provider 被 TTL 包装调用且实参非空串
3016
- expect(provider).toHaveBeenCalledTimes(1)
3017
- expect(provider.mock.calls[0][0]).toBe(flatMain)
737
+ it('⑧ 锚可解析 + 库可开 + schema 兼容,但库内 session 行已删 → zcode_session_not_found', async () => {
738
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
739
+ writeZcodeManifest(SA_M)
740
+ // 模拟 zcode 侧 GC:删 sess_bbb 的全部行(db 文件与 schema 保留)
741
+ const db = new DatabaseSync(dbPath)
742
+ try {
743
+ db.exec("DELETE FROM part WHERE session_id = 'sess_bbb'")
744
+ db.exec("DELETE FROM message WHERE session_id = 'sess_bbb'")
745
+ db.exec("DELETE FROM session WHERE id = 'sess_bbb'")
746
+ } finally {
747
+ db.close()
748
+ }
749
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
750
+ /zcode_session_not_found[\s\S]*👉[\s\S]*原 sa-id[\s\S]*family/,
751
+ )
752
+ })
3018
753
  })
3019
754
 
3020
- it('④ provider 抛错 → handler 不抛,降级首条 user 匹配(标题留空,错误不外泄)', async () => {
3021
- const id = 'err-handler-01'
3022
- await writeSession(id, { firstUserText: 'handler 降级命中文本' })
3023
- const provider = vi.fn(async () => {
3024
- throw new Error('listAll secret boom')
755
+ // ---------------------------------------------------------------------------
756
+ // 驱动探测失败的错误契约(跨包漂移检查 + L4 包装形态映射)
757
+ // ---------------------------------------------------------------------------
758
+
759
+ describe('驱动探测失败错误范围:跨包契约 + L4 包装形态映射', () => {
760
+ it('跨包契约:SQLITE_DRIVER_UNSUPPORTED_MARK 子串在 zcode-session-source sqlite-driver 源文内(漂移即红)', () => {
761
+ // 驱动探测错误无专用错误类型(该包不导出子类),reader 按消息特征识别——mark 与
762
+ // sqlite-driver 源内错误字面量的绑定由本测试机器锚定(entry-anchor.test.ts 协议
763
+ // 字面量跨包漂移检查同范式):源文措辞变更不再含 mark → host_unsupported 契约死亡,红。
764
+ const source = readFileSync(
765
+ new URL('../../../../../packages/zcode-session-source/src/sqlite-driver.ts', import.meta.url),
766
+ 'utf8',
767
+ )
768
+ expect(source).toContain(SQLITE_DRIVER_UNSUPPORTED_MARK)
3025
769
  })
3026
- const r = await findWithProvider('handler 降级命中', provider)
3027
- const d = r.details as { matches: Array<{ sessionId: string; name?: string }> }
3028
- expect(d.matches).toHaveLength(1)
3029
- expect(d.matches[0].sessionId).toBe(id)
3030
- expect(d.matches[0].name).toBeUndefined()
3031
- // 错误细节不进工具输出(降级不是错误)
3032
- expect(r.content[0].text).not.toContain('boom')
3033
- })
3034
770
 
3035
- it('⑤ TTL 缓存(fake timers):TTL 内不重调、过期重调、目录 mtime 变化失效', async () => {
3036
- const id = 'ttl-00000001'
3037
- const p = await writeSession(id, { firstUserText: '无关' })
3038
- const provider = vi.fn(async (dir: string) =>
3039
- dir === flatMain
3040
- ? [{ path: p, id, cwd: '/demo', name: '标题', modified: new Date() }]
3041
- : [],
3042
- )
3043
- // 仅 fake Date(缓存 TTL 判定用),fs/performance 走真实实现
3044
- vi.useFakeTimers({ toFake: ['Date'] })
3045
- try {
3046
- // 首查:cache miss → 调 1 次
3047
- await findWithProvider('zzznohit9q', provider)
3048
- expect(provider).toHaveBeenCalledTimes(1)
3049
- // TTL 内二查:命中缓存不重调
3050
- await findWithProvider('zzznohit9q', provider)
3051
- expect(provider).toHaveBeenCalledTimes(1)
3052
- // TTL 过期 → 重调
3053
- vi.setSystemTime(Date.now() + METADATA_CACHE_TTL_MS + 1)
3054
- await findWithProvider('zzznohit9q', provider)
3055
- expect(provider).toHaveBeenCalledTimes(2)
3056
- // TTL 内但目录 mtime 变化 → 失效重调
3057
- const bumped = new Date(Date.now() + 60_000)
3058
- await utimes(flatMain, bumped, bumped)
3059
- await findWithProvider('zzznohit9q', provider)
3060
- expect(provider).toHaveBeenCalledTimes(3)
3061
- } finally {
3062
- vi.useRealTimers()
3063
- }
3064
- })
3065
- })
3066
-
3067
- // ---------------------------------------------------------------------------
3068
- // u12 跨会话内容检索(fixture,§2 目标 5 / §8.2 V8:窄化前置 + 字节上限 + 渲染)
3069
- // 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
3070
- // ---------------------------------------------------------------------------
3071
-
3072
- describe('u12 跨会话内容检索(fixture,V8)', () => {
3073
- let tmp: string
3074
- /** main 根(平铺,[default] 根直扫)与 subagent 根 */
3075
- let mainDir: string
3076
- const SLUG = '--demo-cwd--'
3077
- // uuidv7 风格形态(find 输出的完整 id 即此形态;u12 索引按 header id,不依赖文件名反推)
3078
- const mainId = (n: number) => `019e6c96-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
3079
- const subId = (n: number) => `019e6c96-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
771
+ it('L4 包装形态映射:SqliteUnreadableError(message 含 mark,last failure 并入)→ host_unsupported(R1 回归锚)', () => {
772
+ // 生产路径上驱动探测错误恒经 recovery L4 包装(last failure 以字符串并入
773
+ // SqliteUnreadableError.message,mark 随之存活)——直接构造该形态,锁定
774
+ // instanceof 分支内的二次判别(分支顺序错误 → 误落 db_unreadable,本用例红)。
775
+ const wrapped = new SqliteUnreadableError(
776
+ ['L1-direct', 'L2-immutable'],
777
+ 'zcode session db unreadable (recovery ladder exhausted): /tmp/none.sqlite — ' +
778
+ `last failure: 当前 node 运行时${SQLITE_DRIVER_UNSUPPORTED_MARK}(需 >=22.13),且宿主非 bun 运行时`,
779
+ )
780
+ const msg = zcodeReadErrorMessage(wrapped, agentDir)
781
+ expect(msg).toContain('zcode_host_unsupported')
782
+ expect(msg).not.toContain('zcode_db_unreadable')
783
+ })
3080
784
 
3081
- /**
3082
- * 写多 turn session(链式 parentId——buildTreeView leaf 回溯需要,无链的后行 entry
3083
- * 会被当旁支过滤,MF-5 段同款教训)。turn 分段:user 开新 turn,assistant 并入当前。
3084
- */
3085
- async function writeSession(
3086
- id: string,
3087
- turns: Array<{ role: 'user' | 'assistant'; text: string }>,
3088
- opts?: { subagent?: boolean },
3089
- ): Promise<void> {
3090
- const dir = opts?.subagent
3091
- ? resolveStoreSubagentSessionsDir(join(tmp, 'agent'), '0aaaaaaa-bbbb-7ccc-dddd-0000000000s2')
3092
- : join(tmp, 'agent', 'sessions')
3093
- await mkdir(dir, { recursive: true })
3094
- const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
3095
- turns.forEach((t, i) => {
3096
- lines.push(
3097
- JSON.stringify({
3098
- type: 'message',
3099
- id: `${id}-m${i}`,
3100
- parentId: i === 0 ? id : `${id}-m${i - 1}`,
3101
- message: { role: t.role, content: [{ type: 'text', text: t.text }] },
3102
- }),
785
+ it('无 mark 的 SqliteUnreadableError 维持 db_unreadable 面(对照:attempted 链进 detail)', () => {
786
+ const plain = new SqliteUnreadableError(
787
+ ['L1-direct'],
788
+ 'zcode session db unreadable (recovery ladder exhausted): /tmp/none.sqlite — last failure: file is not a database',
3103
789
  )
790
+ const msg = zcodeReadErrorMessage(plain, agentDir)
791
+ expect(msg).toContain('zcode_db_unreadable')
792
+ expect(msg).toContain('L1-direct')
793
+ expect(msg).not.toContain('zcode_host_unsupported')
3104
794
  })
3105
- await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
3106
- }
3107
795
 
3108
- /** 造大文件:追加一条 2KB filler 文本(字节上限用例用,pattern 避开 filler 字符)。 */
3109
- async function writeFatSession(id: string): Promise<void> {
3110
- await writeSession(id, [
3111
- { role: 'user', text: `filler ${'x'.repeat(2048)}` },
3112
- { role: 'assistant', text: 'done' },
3113
- ])
3114
- }
796
+ it('跨包契约:ZCODE_DB_MISSING_MARK 子串在 zcode-session-source sqlite-access 源文内(漂移即红)', () => {
797
+ // 库文件缺失(openZcodeSessionDb 开头 existsSync 失败)无专用错误类型(抛普通
798
+ // Error),reader 按消息特征识别(SQLITE_DRIVER_UNSUPPORTED_MARK 契约测试同范式):
799
+ // sqlite-access 源文措辞变更不再含 mark → db_unreadable 面死亡、TOCTOU 缺失误标
800
+ // schema_drift,红。
801
+ const source = readFileSync(
802
+ new URL('../../../../../packages/zcode-session-source/src/sqlite-access.ts', import.meta.url),
803
+ 'utf8',
804
+ )
805
+ expect(source).toContain(ZCODE_DB_MISSING_MARK)
806
+ })
3115
807
 
3116
- interface CrossDetails {
3117
- rejected?: boolean
3118
- candidateCount?: number
3119
- pattern: string
3120
- scope: string
3121
- degraded: boolean
3122
- byteBudget: number
3123
- scannedBytes: number
3124
- scanned: Array<{
3125
- sessionId: string
3126
- source: string
3127
- name?: string
3128
- hits: Array<{ turnIndex: number; entryIndex: number; role: string; matchSnippet: string }>
3129
- hitsTotal?: number
3130
- }>
3131
- skipped: Array<{ sessionId: string; reason: string; sizeBytes?: number }>
3132
- truncated: boolean
3133
- }
808
+ it('TOCTOU 窗口:开库期库文件缺失普通 Error → db_unreadable 面(不误标 schema_drift,§3.4 第 4 行)', () => {
809
+ // 白名单闸 existsSync 通过后、openZcodeSessionDb 开库前文件被删的窗口:sqlite-access
810
+ // 抛普通 Error「db 文件不存在:<dbPath>」——落 db_unreadable(环境恢复指引),编程
811
+ // 错误兜底(schema_drift)只接真正的查询期 schema 漂移域。
812
+ const msg = zcodeReadErrorMessage(new Error(`db 文件不存在:/tmp/fixture/db.sqlite`), agentDir)
813
+ expect(msg).toContain('zcode_db_unreadable')
814
+ expect(msg).toContain('db 文件不存在:/tmp/fixture/db.sqlite')
815
+ expect(msg).toContain('👉')
816
+ expect(msg).not.toContain('zcode_schema_drift')
817
+ })
3134
818
 
3135
- beforeEach(async () => {
3136
- tmp = await mkdtemp(join(tmpdir(), 'tool-handler-u12-'))
3137
- mainDir = join(tmp, 'agent', 'sessions')
3138
- })
3139
- afterEach(async () => {
3140
- await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
819
+ it('对照:不含缺失 mark 的普通 Error 仍落 schema_drift 兜底(编程错误域语义不扩)', () => {
820
+ const msg = zcodeReadErrorMessage(new Error('TypeError: cannot read properties of undefined'), agentDir)
821
+ expect(msg).toContain('zcode_schema_drift')
822
+ expect(msg).not.toContain('zcode_db_unreadable')
823
+ })
3141
824
  })
3142
825
 
3143
- it('① 窄化拒绝:候选超上限 → 拒绝文案 + 先 find 指引,不深扫(V8)', async () => {
3144
- // 库中放一个真实可命中的 session——拒绝路径不得把它扫出来
3145
- await writeSession(mainId(1), [
3146
- { role: 'user', text: '讨论 adj_factor 的历史数据' },
3147
- { role: 'assistant', text: 'adj_factor 表在 dws 库' },
3148
- ])
3149
- const ids = Array.from({ length: MULTI_SEARCH_MAX_SESSIONS + 1 }, (_, i) => mainId(i + 10))
3150
- const r = await handleSessionRead(
3151
- { action: 'search', session: ids.join(','), pattern: 'adj_factor' },
3152
- { agentDir: join(tmp, 'agent') },
3153
- )
3154
- const text = r.content[0].text
3155
- expect(text).toContain('跨会话检索已拒绝')
3156
- expect(text).toContain(`超出单次上限 ${MULTI_SEARCH_MAX_SESSIONS} 个`)
3157
- expect(text).toContain('先窄化')
3158
- expect(text).toContain('action:"find"')
3159
- expect(text).toContain('action:"search", session:"<id1>,<id2>"')
3160
- const d = r.details as Partial<CrossDetails>
3161
- expect(d.rejected).toBe(true)
3162
- expect(d.candidateCount).toBe(MULTI_SEARCH_MAX_SESSIONS + 1)
3163
- // 不深扫:可命中内容与命中结构都不出现
3164
- expect(text).not.toContain('adj_factor 表在 dws 库')
3165
- expect(text).not.toContain('hits:')
3166
- expect(d.scanned).toBeUndefined()
3167
- })
826
+ // ---------------------------------------------------------------------------
827
+ // family 接线(buildFamilyFromFs zcode 分支 + sa-id family 路由)
828
+ // ---------------------------------------------------------------------------
3168
829
 
3169
- it('②a 字节上限截断:预算按序消耗,放不下的文件起整段停止并报告已扫范围', async () => {
3170
- const A = mainId(1)
3171
- const B = mainId(2)
3172
- await writeSession(A, [
3173
- { role: 'user', text: 'small session' },
3174
- { role: 'assistant', text: 'ok' },
3175
- ])
3176
- await writeFatSession(B)
3177
- // 直接调导出函数注入小预算(工具路径走默认 64MB,fixture 不构造 64MB 文件)
3178
- const r = await searchAcrossSessions([A, B], 'zzzkw', { agentDir: join(tmp, 'agent') }, {
3179
- byteBudget: 1024,
830
+ describe('family:zcode 节点接线(D5-1 两态)与 sa-id family 路由', () => {
831
+ beforeEach(() => {
832
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
833
+ writeZcodeManifest(SA_M)
834
+ writeRootSessionFile()
3180
835
  })
3181
- const d = r.details as CrossDetails
3182
- // A 扫完(无命中也入 scanned),B 放不下 → byte-budget,且其 sizeBytes 有报告
3183
- expect(d.scanned).toHaveLength(1)
3184
- expect(d.scanned[0].sessionId).toBe(A)
3185
- expect(d.scannedBytes).toBeGreaterThan(0)
3186
- expect(d.scannedBytes).toBeLessThanOrEqual(1024)
3187
- expect(d.skipped).toHaveLength(1)
3188
- expect(d.skipped[0]).toMatchObject({ sessionId: B, reason: 'byte-budget' })
3189
- expect(d.skipped[0].sizeBytes).toBeGreaterThan(1024)
3190
- const text = r.content[0].text
3191
- expect(text).toContain('not scanned')
3192
- expect(text).toContain(B)
3193
- expect(text).toContain(A)
3194
- // 未扫 id 附单独检索指引
3195
- expect(text).toContain(`action:"search", session:"${B}", pattern:"zzzkw"`)
3196
- })
3197
836
 
3198
- it('②b 首个文件即超预算 → 0 扫描,整段未扫报告(不突破上限)', async () => {
3199
- const B = mainId(2)
3200
- await writeFatSession(B)
3201
- const r = await searchAcrossSessions([B], 'zzzkw', { agentDir: join(tmp, 'agent') }, {
3202
- byteBudget: 512,
837
+ it('sa-id family:zcode 路由 → rootSessionId 视图,subagents 含 zcode 节点且 cleanedUp=false', async () => {
838
+ const r = await handleSessionRead({ action: 'family', session: SA_M }, signals())
839
+ const text = r.content[0]?.text ?? ''
840
+ expect(text).toContain(SA_M)
841
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
842
+ const node = family.subagents.find((s) => s.sessionId === SA_M)
843
+ expect(node?.cleanedUp).toBe(false) // 库可达态(D5-1:锚可解析 ∧ 库文件存在)
3203
844
  })
3204
- const d = r.details as CrossDetails
3205
- expect(d.scanned).toHaveLength(0)
3206
- expect(d.scannedBytes).toBe(0)
3207
- expect(d.skipped).toEqual([{ sessionId: B, reason: 'byte-budget', sizeBytes: expect.any(Number) }])
3208
- expect(r.content[0].text).toContain('not scanned')
3209
- })
3210
845
 
3211
- it('③ 命中渲染:session 列表(完整 id+source+标题)+ turn 索引/角色/片段 + 可执行调用串', async () => {
3212
- const A = mainId(1)
3213
- const B = subId(1)
3214
- const C = mainId(2)
3215
- // A:T000 两命中(user+assistant)+ T001 一命中(assistant)
3216
- await writeSession(A, [
3217
- { role: 'user', text: '查询 adj_factor 的历史数据' },
3218
- { role: 'assistant', text: 'adj_factor 表在 dws 库' },
3219
- { role: 'user', text: '换一个话题' },
3220
- { role: 'assistant', text: '再看 adj_factor 的最新分区' },
3221
- ])
3222
- await writeSession(B, [
3223
- { role: 'user', text: 'subagent task 无关键词' },
3224
- { role: 'assistant', text: '结果里含 adj_factor 字样' },
3225
- ], { subagent: true })
3226
- await writeSession(C, [
3227
- { role: 'user', text: '完全没有关键词的内容' },
3228
- ])
3229
- const provider: SessionMetadataProvider = async (dir) =>
3230
- dir === mainDir
3231
- ? [{ path: join(mainDir, `${A}.jsonl`), id: A, cwd: '/demo', name: '太保寿险深度研究', modified: new Date() }]
3232
- : []
3233
- const r = await handleSessionRead(
3234
- { action: 'search', session: `${A},${B},${C}`, pattern: 'adj_factor' },
3235
- { agentDir: join(tmp, 'agent'), liveSessionDir: mainDir },
3236
- undefined,
3237
- provider,
3238
- )
3239
- const text = r.content[0].text
3240
- // header:命中/已扫计数 + 默认预算标注(handleSessionRead 路径走 SEARCH_SCAN_BYTE_BUDGET,
3241
- // formatScanBytes 的 MB 形态恒 1 位小数)
3242
- expect(text).toContain('2/3 session(s) hit for /adj_factor/')
3243
- expect(text).toContain(`${(SEARCH_SCAN_BYTE_BUDGET / (1024 * 1024)).toFixed(1)}MB budget`)
3244
- // 命中块:完整 id + source + 标题(provider 注入)+ 命中数,列表序 = 传入序
3245
- expect(text).toContain(`1. ${A} · main · 太保寿险深度研究(3 hits)`)
3246
- expect(text).toContain(`2. ${B} · subagent(1 hit)`)
3247
- // turn 索引 + entry 序号 + 角色 + 片段
3248
- expect(text).toContain(' T000 #0 user: ')
3249
- expect(text).toContain(' T000 #1 assistant: ')
3250
- expect(text).toContain(' T001 #1 assistant: ')
3251
- // 可直接执行的调用串(真实参数 = pattern)
3252
- expect(text).toContain(`↳ session_read { action:"search", session:"${A}", pattern:"adj_factor" }`)
3253
- expect(text).toContain(`↳ session_read { action:"search", session:"${B}", pattern:"adj_factor" }`)
3254
- // 已扫无命中段
3255
- expect(text).toContain('scanned, no hit:')
3256
- expect(text).toContain(`${C} · main`)
3257
- const d = r.details as CrossDetails
3258
- expect(d.rejected).toBeUndefined()
3259
- expect(d.truncated).toBe(false)
3260
- expect(d.degraded).toBe(false)
3261
- expect(d.byteBudget).toBe(SEARCH_SCAN_BYTE_BUDGET)
3262
- expect(d.skipped).toEqual([])
3263
- expect(d.scanned.map((s) => s.sessionId)).toEqual([A, B, C])
3264
- expect(d.scanned[0].hits).toHaveLength(3)
3265
- expect(d.scanned[0].name).toBe('太保寿险深度研究')
3266
- expect(d.scanned[1].hits).toHaveLength(1)
3267
- // B 只有一条 user → 单 turn,assistant 命中在 T000 #1
3268
- expect(d.scanned[1].hits[0]).toMatchObject({
3269
- turnIndex: 0,
3270
- entryIndex: 1,
3271
- role: 'assistant',
846
+ it('sa-id family recursive=true:rootSessionId 反查回填 fileName → 树 main root 填真实 session 路径(MF-1 链路)', async () => {
847
+ const r = await handleSessionRead({ action: 'family', session: SA_M, recursive: true }, signals())
848
+ const tree = (
849
+ r.details as { tree: { root: { type: string; sessionId: string; sessionFile?: string } } }
850
+ ).tree
851
+ expect(tree.root.type).toBe('main')
852
+ expect(tree.root.sessionId).toBe(ROOT_SESSION)
853
+ // 反查命中默认根的 root session 文件(writeRootSessionFile 落的路径),非空串占位
854
+ expect(tree.root.sessionFile).toBe(join(agentDir, 'sessions', `1700000000000_${ROOT_SESSION}.jsonl`))
3272
855
  })
3273
- expect(d.scanned[2].hits).toHaveLength(0)
3274
- })
3275
856
 
3276
- it('④ 无命中形态 + not-found 报告(不在库中的 id 不中断整体)', async () => {
3277
- const A = mainId(1)
3278
- const GHOST = mainId(99)
3279
- await writeSession(A, [{ role: 'user', text: '普通内容' }])
3280
- const r = await handleSessionRead(
3281
- { action: 'search', session: `${A},${GHOST}`, pattern: 'zzznothing' },
3282
- { agentDir: join(tmp, 'agent') },
3283
- )
3284
- const text = r.content[0].text
3285
- expect(text).toContain('0/1 session(s) hit for /zzznothing/')
3286
- expect(text).toContain('scanned, no hit:')
3287
- expect(text).toContain(`${A} · main`)
3288
- expect(text).toContain('换更精确 pattern')
3289
- expect(text).toContain('not found in library')
3290
- expect(text).toContain(`- ${GHOST}`)
3291
- const d = r.details as CrossDetails
3292
- expect(d.scanned).toHaveLength(1)
3293
- expect(d.scanned[0].hits).toHaveLength(0)
3294
- expect(d.skipped).toEqual([{ sessionId: GHOST, reason: 'not-found' }])
857
+ it('rootSessionId family:buildFamilyFromFs 接线后 zcode 节点进列表(真实 family action 输出)', async () => {
858
+ const r = await handleSessionRead({ action: 'family', session: ROOT_SESSION }, signals())
859
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
860
+ expect(family.subagents.map((s) => s.sessionId)).toContain(SA_M)
861
+ })
862
+
863
+ it('库不存在态:dbPath 改指白名单布局内缺席路径 → cleanedUp=true(GC 语义,不报错)', async () => {
864
+ writeZcodeManifest(SA_M, {
865
+ sessionRef: { sessionId: SESS_B, dbPath: join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1), 'gone.sqlite') },
866
+ })
867
+ const r = await handleSessionRead({ action: 'family', session: ROOT_SESSION }, signals())
868
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
869
+ expect(family.subagents.find((s) => s.sessionId === SA_M)?.cleanedUp).toBe(true)
870
+ })
871
+
872
+ it('反向断言:库文件在但库内 session 行被删 → family 仍 cleanedUp=false(列表视图不开库的分工)', async () => {
873
+ const db = new DatabaseSync(dbPath)
874
+ try {
875
+ db.exec("DELETE FROM part WHERE session_id = 'sess_bbb'")
876
+ db.exec("DELETE FROM message WHERE session_id = 'sess_bbb'")
877
+ db.exec("DELETE FROM session WHERE id = 'sess_bbb'")
878
+ } finally {
879
+ db.close()
880
+ }
881
+ const r = await handleSessionRead({ action: 'family', session: ROOT_SESSION }, signals())
882
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
883
+ expect(family.subagents.find((s) => s.sessionId === SA_M)?.cleanedUp).toBe(false)
884
+ })
3295
885
  })
3296
886
 
3297
- it('⑤ 单会话语义无回归:无逗号 session 走原路径(hits 结构),pattern 含逗号不误分流', async () => {
3298
- const A = mainId(1)
3299
- await writeSession(A, [
3300
- { role: 'user', text: 'aaa plugin 内容' },
3301
- { role: 'assistant', text: 'bbb' },
3302
- ])
3303
- // 单 id:原 details.hits 结构与 header 形态不变
3304
- const r = await handleSessionRead(
3305
- { action: 'search', session: A, pattern: 'plugin' },
3306
- { agentDir: join(tmp, 'agent') },
3307
- )
3308
- expect(r.content[0].text).toContain('1 hit(s) for /plugin/')
3309
- const d = r.details as { hits: unknown[]; truncated: boolean; scanned?: unknown }
3310
- expect(d.hits).toHaveLength(1)
3311
- expect(d.truncated).toBe(false)
3312
- expect(d.scanned).toBeUndefined()
3313
- // pattern 含逗号不触发跨会话(分流只看 session)
3314
- const r2 = await handleSessionRead(
3315
- { action: 'search', session: A, pattern: 'aaa,bbb' },
3316
- { agentDir: join(tmp, 'agent') },
3317
- )
3318
- expect((r2.details as { hits: unknown[] }).hits).toHaveLength(0)
3319
- expect((r2.details as { scanned?: unknown }).scanned).toBeUndefined()
3320
- expect(r2.content[0].text).not.toContain('session(s) hit')
887
+ // ---------------------------------------------------------------------------
888
+ // pi 穿插不受影响(场景 3 抽样:zcode 失败调用穿插前后 pi 输出不变)
889
+ // ---------------------------------------------------------------------------
890
+
891
+ describe('pi 穿插不受影响:同 fixture 内 pi 会话读取与 zcode 调用互不干扰', () => {
892
+ beforeEach(() => {
893
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
894
+ writeZcodeManifest(SA_M)
895
+ writePiFixture()
896
+ })
897
+
898
+ it('result:pi sa-id 正文穿插 zcode 失败调用前后一致(PI-SENTINEL 恒在)', async () => {
899
+ // zcode 调用先失败(锚缺位形态)
900
+ writeZcodeManifest('sa-broken-1', { omitEngineHandle: true })
901
+ await expect(handleSessionRead({ action: 'result', session: 'sa-broken-1' }, signals())).rejects.toThrow(
902
+ 'zcode_anchor_missing',
903
+ )
904
+ const pi = await handleSessionRead({ action: 'result', session: 'sa-pi-1' }, signals())
905
+ expect(pi.content[0]?.text).toContain('PI-SENTINEL 正文内容')
906
+ // 再穿插一次 zcode 成功调用,pi 输出仍一致
907
+ const piAgain = await handleSessionRead({ action: 'result', session: 'sa-pi-1' }, signals())
908
+ expect(piAgain.content[0]?.text).toBe(pi.content[0]?.text)
909
+ })
910
+
911
+ it('uuid 形态(resolveSessionId 形态③)与 pi sa-id 现路径零变化', async () => {
912
+ const pi = await handleSessionRead({ action: 'result', session: 'sa-pi-1' }, { agentDir })
913
+ expect(pi.content[0]?.text).toContain('PI-SENTINEL')
914
+ })
3321
915
  })
3322
916
 
3323
- it('⑥ per-session limit 截断:超限 session 标注 >N hits,truncated=true', async () => {
3324
- const A = mainId(1)
3325
- await writeSession(A, [
3326
- { role: 'user', text: 'kw one' },
3327
- { role: 'assistant', text: 'kw two' },
3328
- { role: 'user', text: 'kw three' },
3329
- ])
3330
- const r = await searchAcrossSessions([A], 'kw', { agentDir: join(tmp, 'agent') }, { limit: 2 })
3331
- const d = r.details as CrossDetails
3332
- expect(d.truncated).toBe(true)
3333
- expect(d.scanned[0].hits).toHaveLength(2)
3334
- expect(d.scanned[0].hitsTotal).toBe(3)
3335
- expect(r.content[0].text).toContain('(>3 hits, showing first 2)')
917
+ // ---------------------------------------------------------------------------
918
+ // D4 schema 零变化(快照逐字节对照 + 合法形态冒烟)
919
+ // ---------------------------------------------------------------------------
920
+
921
+ /** `session_read` 输入 schema 的 JSON 序列化快照(防无意漂移的逐字节锚)。
922
+ *
923
+ * 快照锚的意图 = 无意漂移拦截(参数增删 / 文案改动即红,迫使有意确认);
924
+ * 有意的声明侧压缩须同批重新生成快照。当前快照 = tool-declaration-slimming
925
+ * U-A1 压缩终态(D1 三层分工:action 功能语义唯一承载点 = 枚举 description;
926
+ * 工具 description 只留定位 + 边界;guidelines 只留纪律)。重新生成:
927
+ * tsx 起 extension 捕获 registerTool 的 parameters 后 JSON.stringify。
928
+ */
929
+ const SCHEMA_SNAPSHOT =
930
+ '{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["find","famil'
931
+ + 'y","outline","expand","detail","search","export","extract","workflow","result","doctor"],"descriptio'
932
+ + 'n":"find (locate session), family (relations; recursive=tree), outline (turn overview), expand (one '
933
+ + 'turn), detail (full text), search (grep, cross-session, ≤10 ids), export (to file), extract (message'
934
+ + 's/commands/files/commits/tool results), workflow (run overview), result (subagent final text, batch '
935
+ + '≤10), doctor (environment diagnostics)."},"session":{"type":"string","description":"Session id, uuid'
936
+ + ' fragment (e6c96), subagent id (sa-xxx), or .jsonl path. Required by every action except find/doctor'
937
+ + '; result/search accept ≤10 comma-separated ids. # prefix auto-stripped."},"query":{"type":"string","'
938
+ + 'description":"find: uuid fragment / filename / name keyword / \\"recent\\" (recent N)."},"turns":{"typ'
939
+ + 'e":"string","description":"detail/extract: turn range \\"T013-T015\\" or \\"T013\\"."},"turn":{"type":"s'
940
+ + 'tring","description":"expand: turn \\"T013\\"."},"pattern":{"type":"string","description":"search: sub'
941
+ + 'string or regex."},"scope":{"type":"string","enum":["all","user","assistant","toolResult"],"descript'
942
+ + 'ion":"search: filter scope. Default all."},"format":{"type":"string","enum":["outline","full","famil'
943
+ + 'y"],"description":"export: materialized form. Default outline."},"includeToolResult":{"type":"boolea'
944
+ + 'n","description":"detail/export: include toolResult text. Default false."},"includeThinking":{"type"'
945
+ + ':"boolean","description":"detail: include thinking blocks. Default false."},"allBranches":{"type":"b'
946
+ + 'oolean","description":"outline/export: include abandoned side-branches. Default false."},"granularit'
947
+ + 'y":{"type":"string","enum":["turn","entry"],"description":"outline: turn-level or entry-flat. Defaul'
948
+ + 't turn."},"cwd":{"type":"string","description":"find: filter by cwd."},"source":{"type":"string","en'
949
+ + 'um":["main","subagent"],"description":"\\"main\\" = sessions/, \\"subagent\\" = subagents/. Default both'
950
+ + '."},"limit":{"type":"number","minimum":1,"description":"find/search: max results (default 20). resul'
951
+ + 't: max chars per item (default 8000, truncated with pointer)."},"what":{"type":"string","enum":["use'
952
+ + 'r-messages","commands","files","commits","tool-results"],"description":"extract: what to pull (requi'
953
+ + 'red)."},"tool":{"type":"string","description":"extract: filter by tool name (e.g. \\"bash\\")."},"runI'
954
+ + 'd":{"type":"string","description":"workflow: focus one run (omit = all)."},"recursive":{"type":"bool'
955
+ + 'ean","description":"family: nested execution tree. Default false (flat)."},"includeSubagents":{"type'
956
+ + '":"boolean","description":"doctor: also scan the subagent session root. Default false."}}}';
957
+
958
+ describe('session_read schema 快照(D4:不加任何工具参数,逐字节一致)', () => {
959
+ it('TypeBox schema JSON 序列化与基线快照逐字节一致', () => {
960
+ const registerTool = vi.fn()
961
+ const pi = { registerTool, registerCommand: vi.fn(), addAutocompleteProvider: vi.fn(), on: vi.fn() }
962
+ ;(sessionReaderExtension as unknown as (p: unknown) => void)(pi)
963
+ const toolDef = registerTool.mock.calls[0][0] as { parameters: unknown }
964
+ expect(JSON.stringify(toolDef.parameters)).toBe(SCHEMA_SNAPSHOT)
965
+ })
966
+
967
+ it('既有合法调用形态仍通过 schema 校验(无新增必填参数)', () => {
968
+ const registerTool = vi.fn()
969
+ const pi = { registerTool, registerCommand: vi.fn(), addAutocompleteProvider: vi.fn(), on: vi.fn() }
970
+ ;(sessionReaderExtension as unknown as (p: unknown) => void)(pi)
971
+ const schema = (registerTool.mock.calls[0][0] as { parameters: unknown }).parameters
972
+ expect(Check(schema, { action: 'result', session: 'sa-xxx' })).toBe(true)
973
+ expect(Check(schema, { action: 'outline', session: 'e6c96' })).toBe(true)
974
+ })
3336
975
  })
3337
976
 
3338
- it('⑦ abort 信号透传:跨会话扫描中断抛错(与单会话 MF-5 语义一致)', async () => {
3339
- const A = mainId(1)
3340
- await writeSession(A, [{ role: 'user', text: 'content' }])
3341
- const ac = new AbortController()
3342
- ac.abort()
3343
- await expect(
3344
- searchAcrossSessions([A], 'kw', { agentDir: join(tmp, 'agent') }, { signal: ac.signal }),
3345
- ).rejects.toThrow(/中断/)
977
+ // ---------------------------------------------------------------------------
978
+ // D2 锚词保留清单(index.ts guidelines 上方注释登记的清单在此逐词 grep 锚定)
979
+ // ---------------------------------------------------------------------------
980
+
981
+ describe('session_read 锚词保留清单(D2:guidelines 纪律逐词锚定)', () => {
982
+ // SCHEMA_SNAPSHOT 只逐字节锚 parameters schema,不覆盖 guidelines——本块是
983
+ // guidelines 锚词的机器防线:弱模型回归(P7/PB-weak)后对本工具声明侧的
984
+ // 再压缩 / 措辞改动,逐词红灯迫使有意确认。清单与 index.ts 注释登记一致:
985
+ // 1. "Progressive reading" —— 阅读纪律 outline → expand → detail
986
+ // 2. "Never read raw .jsonl" —— 禁直读原始 session 文件
987
+ // 3. "sa- ids route" —— zcode 路由对 LLM 的唯一可见部分(U9)
988
+ // 4. "👉 recovery hint" —— 错误范围恢复指针契约
989
+ // 5. "doctor" —— 环境自检入口(另被上方快照 action 枚举间接锚定)
990
+ it('guidelines 5 锚词逐个在位(缺一词即红)', () => {
991
+ const registerTool = vi.fn()
992
+ const pi = { registerTool, registerCommand: vi.fn(), addAutocompleteProvider: vi.fn(), on: vi.fn() }
993
+ ;(sessionReaderExtension as unknown as (p: unknown) => void)(pi)
994
+ const toolDef = registerTool.mock.calls[0][0] as { promptGuidelines: string[] }
995
+ const guidelines = toolDef.promptGuidelines.join('\n')
996
+ expect(guidelines).toContain('Progressive reading')
997
+ expect(guidelines).toContain('Never read raw .jsonl')
998
+ expect(guidelines).toContain('sa- ids route')
999
+ expect(guidelines).toContain('👉 recovery hint')
1000
+ expect(guidelines).toContain('doctor')
1001
+ })
3346
1002
  })
3347
- })
1003
+ });