@zhushanwen/pi-session-reader 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +100 -0
  2. package/package.json +7 -4
  3. package/src/__tests__/cross-package-subagent-core.test.ts +36 -44
  4. package/src/__tests__/entry-anchor.test.ts +226 -0
  5. package/src/__tests__/env.test.ts +54 -54
  6. package/src/__tests__/execution-tree.test.ts +6 -0
  7. package/src/__tests__/family-zcode.test.ts +255 -0
  8. package/src/__tests__/family.test.ts +1 -1
  9. package/src/__tests__/find.test.ts +24 -1
  10. package/src/__tests__/index.test.ts +1 -1
  11. package/src/__tests__/parser.test.ts +1 -2
  12. package/src/__tests__/real-data.ts +22 -9
  13. package/src/__tests__/render.test.ts +1 -2
  14. package/src/__tests__/result.test.ts +2 -2
  15. package/src/__tests__/roots.test.ts +1 -1
  16. package/src/__tests__/subagents.test.ts +1 -4
  17. package/src/__tests__/tool-handler.test.ts +76 -85
  18. package/src/__tests__/toolcall.test.ts +1 -1
  19. package/src/__tests__/tree.test.ts +1 -1
  20. package/src/__tests__/turns.test.ts +1 -2
  21. package/src/__tests__/zcode-manifest.test.ts +361 -0
  22. package/src/__tests__/zcode-routing.test.ts +751 -0
  23. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  24. package/src/core/execution-tree.ts +3 -1
  25. package/src/core/family.ts +80 -1
  26. package/src/core/render.ts +2 -3
  27. package/src/core/toolcall.ts +3 -4
  28. package/src/core/tree.ts +1 -1
  29. package/src/core/turns.ts +1 -1
  30. package/src/core/units.ts +14 -0
  31. package/src/discovery/entry-anchor.ts +108 -0
  32. package/src/discovery/env.ts +41 -41
  33. package/src/discovery/find.ts +1 -1
  34. package/src/discovery/roots.ts +2 -2
  35. package/src/discovery/session-header.ts +30 -69
  36. package/src/discovery/subagents.ts +9 -2
  37. package/src/discovery/whitelist.ts +87 -0
  38. package/src/discovery/workflows.ts +1 -1
  39. package/src/discovery/zcode-manifest.ts +277 -0
  40. package/src/doctor.ts +5 -70
  41. package/src/index.ts +9 -2
  42. package/src/result-action.ts +15 -6
  43. package/src/search-across.ts +2 -5
  44. package/src/tool-format.ts +455 -0
  45. package/src/tool-handler.ts +321 -325
  46. package/src/zcode-anchor-classify.ts +60 -0
  47. package/vitest.config.ts +2 -4
  48. package/src/core/parser.ts +0 -167
@@ -0,0 +1,751 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { DatabaseSync } from 'node:sqlite'
6
+ import { Check } from 'typebox/value'
7
+
8
+ import { handleSessionRead } from '../tool-handler.js'
9
+ import {
10
+ SQLITE_DRIVER_UNSUPPORTED_MARK,
11
+ ZCODE_DB_MISSING_MARK,
12
+ zcodeReadErrorMessage,
13
+ } from '../tool-format.js'
14
+ import { SqliteUnreadableError } from '@zhushanwen/zcode-session-source'
15
+ import sessionReaderExtension from '../index.js'
16
+ import { setPiHandle } from '@zhushanwen/pi-extension-logger'
17
+
18
+ // ============================================================
19
+ // U9 路由 + 白名单闸 + 错误面集成测试(design session-reader-shared-core
20
+ // §3.1 三层定位链 / §3.4 七码 + 检查顺序三段递进 / §4 场景 1a 变体 M/E +
21
+ // 场景 2 family + 场景 5 八类失败 + 场景 3 pi 零回归 + D4 schema 零变化)。
22
+ //
23
+ // fixture 骨架(mkdtemp 自建自删):root/agent = agentDir(dirname = dataDir)、
24
+ // root/engines/zcode/session-db/db.sqlite = 隔离库(白名单精确命中)、
25
+ // root/live-sessions = liveSessionDir(entry 兜底候选根)、
26
+ // root/agent/sessions = main 默认根(family/rootSession 视图的 header 来源)。
27
+ //
28
+ // 行集非平凡性:sess_aaa(ZASENTINEL)与 sess_bbb(ZBSENTINEL + ZZQFIXTURE +
29
+ // toolCall 对 + compaction part)两个可区分行集——「取首条/合并/读错行集」的
30
+ // 实现会直接失败。manifest 锚与 entry 锚刻意指向不同 session(变体 M 的
31
+ // instrument:读到 manifest 的 sess_bbb 而非 entry 的 sess_aaa = 走对主路径)。
32
+ // ============================================================
33
+
34
+ const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
35
+
36
+ const SA_M = 'sa-fix1' // 变体 M / 八类失败主 sa-id
37
+ const SA_E = 'sa-fix2' // 变体 E(manifest 缺位,entry 兜底)
38
+ const SESS_A = 'sess_aaa'
39
+ const SESS_B = 'sess_bbb'
40
+ const ROOT_SESSION = 'root-session-1'
41
+ const BASE_MS = 1758379200000
42
+
43
+ let root: string
44
+ let agentDir: string
45
+ let dbPath: string
46
+ let liveDir: string
47
+ let appendEntries: ReturnType<typeof vi.fn>
48
+
49
+ beforeEach(() => {
50
+ root = mkdtempSync(join(tmpdir(), 'zcode-routing-test-'))
51
+ agentDir = join(root, 'agent')
52
+ liveDir = join(root, 'live-sessions')
53
+ mkdirSync(agentDir, { recursive: true })
54
+ mkdirSync(liveDir, { recursive: true })
55
+ dbPath = join(root, ...ISOLATED_DB_SEGMENTS)
56
+ // 结构化日志捕获:logger.warn 走 appendEntry 通道(不进 LLM 可见面——本文件同时
57
+ // 是「降级留痕不进 LLM 可见面」契约的断言面)。测试后清注入防跨文件泄漏。
58
+ appendEntries = vi.fn()
59
+ setPiHandle({ appendEntry: appendEntries } as unknown as Parameters<typeof setPiHandle>[0])
60
+ })
61
+ afterEach(() => {
62
+ setPiHandle(undefined)
63
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
64
+ })
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // fixture builders
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface SessionRows {
71
+ id: string
72
+ title: string
73
+ messages: Array<{ id: string; role: string; parts: Array<Record<string, unknown>> }>
74
+ }
75
+
76
+ /** sess_aaa 行集:两轮对话,文本带 ZASENTINEL(entry 首条锚指向它)。 */
77
+ function sessARows(): SessionRows {
78
+ return {
79
+ id: SESS_A,
80
+ title: 'sess A title',
81
+ messages: [
82
+ userMsg('m1', 0, '第一轮用户提问 ZASENTINEL 锚'),
83
+ assistantMsg('m2', 1, ['第一轮助手回答 ZASENTINEL']),
84
+ userMsg('m3', 2, '第二轮用户提问'),
85
+ assistantMsg('m4', 3, ['第二轮助手回答 ZASENTINEL']),
86
+ ],
87
+ }
88
+ }
89
+
90
+ /** sess_bbb 行集:两轮 + toolCall 对 + ZZQFIXTURE + compaction(manifest 锚指向它)。 */
91
+ function sessBRows(): SessionRows {
92
+ return {
93
+ id: SESS_B,
94
+ title: 'sess B title',
95
+ messages: [
96
+ userMsg('m1', 0, '派发任务 ZZQFIXTURE 检索锚点'),
97
+ assistantMsg('m2', 1, [], {
98
+ toolPart: {
99
+ type: 'tool',
100
+ callID: 'tc-1',
101
+ tool: 'Bash',
102
+ state: { status: 'completed', input: { command: 'echo hi' }, output: 'hi' },
103
+ },
104
+ extraParts: [{ type: 'text', text: '工具执行完成', time: { start: BASE_MS + 20 } }],
105
+ }),
106
+ userMsg('m3', 2, '继续第二轮'),
107
+ assistantMsg('m4', 3, ['第二轮 ZBSENTINEL 收尾'], {
108
+ extraParts: [{ type: 'compaction', data: { trigger: 'auto' }, time: { start: BASE_MS + 40 } }],
109
+ }),
110
+ ],
111
+ }
112
+ }
113
+
114
+ function userMsg(id: string, seq: number, text: string): SessionRows['messages'][number] {
115
+ return {
116
+ id,
117
+ role: 'user',
118
+ parts: [{ type: 'text', text, time: { start: BASE_MS + seq * 10 } }],
119
+ }
120
+ }
121
+
122
+ function assistantMsg(
123
+ id: string,
124
+ seq: number,
125
+ texts: string[],
126
+ opts: { toolPart?: Record<string, unknown>; extraParts?: Array<Record<string, unknown>> } = {},
127
+ ): SessionRows['messages'][number] {
128
+ const parts: Array<Record<string, unknown>> = []
129
+ if (opts.toolPart) parts.push(opts.toolPart)
130
+ texts.forEach((t, i) => parts.push({ type: 'text', text: t, time: { start: BASE_MS + seq * 10 + i } }))
131
+ if (opts.extraParts) parts.push(...opts.extraParts)
132
+ parts.push({ type: 'step-finish', reason: 'stop', tokens: { input: 1, output: 1, total: 2 }, cost: 0 })
133
+ return { id, role: 'assistant', parts }
134
+ }
135
+
136
+ /**
137
+ * 建真实 fixture 隔离库(WAL + session/message/part/schema_migration 四表,行形态 =
138
+ * zcode-session-source helpers 同款宿主 schema 0.16.5 消费面子集)。
139
+ * 测试进程跑 node(node:sqlite 可用);生产读链的驱动选择在 source 包内。
140
+ */
141
+ function createZcodeDb(path: string, schemaVersion = '0.16.5', sessions: SessionRows[] = []): void {
142
+ mkdirSync(join(path, '..'), { recursive: true })
143
+ const db = new DatabaseSync(path)
144
+ try {
145
+ db.exec('PRAGMA journal_mode=WAL')
146
+ db.exec('BEGIN')
147
+ db.exec(
148
+ 'CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, title TEXT NOT NULL, ' +
149
+ "task_type TEXT NOT NULL DEFAULT 'interactive', time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL)",
150
+ )
151
+ db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, sequence INTEGER, data TEXT NOT NULL)')
152
+ 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)')
153
+ db.exec('CREATE TABLE schema_migration (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, app_version TEXT, time_applied INTEGER NOT NULL)')
154
+ db.prepare('INSERT INTO schema_migration (id, checksum, app_version, time_applied) VALUES (?, ?, ?, ?)').run('0001_seed', 'x', schemaVersion, 1)
155
+ const insSession = db.prepare('INSERT INTO session (id, directory, title, task_type, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)')
156
+ const insMessage = db.prepare('INSERT INTO message (id, session_id, sequence, data) VALUES (?, ?, ?, ?)')
157
+ const insPart = db.prepare('INSERT INTO part (id, message_id, session_id, sequence, data) VALUES (?, ?, ?, ?, ?)')
158
+ for (const [si, s] of sessions.entries()) {
159
+ insSession.run(s.id, '/tmp/fix', s.title, 'interactive', BASE_MS, BASE_MS + si * 10)
160
+ for (const [mi, m] of s.messages.entries()) {
161
+ insMessage.run(`${s.id}_${m.id}`, s.id, mi, JSON.stringify({ role: m.role, time: { created: BASE_MS + mi * 10 } }))
162
+ for (const [pi, p] of m.parts.entries()) {
163
+ insPart.run(`${s.id}_${m.id}_p${pi}`, `${s.id}_${m.id}`, s.id, pi, JSON.stringify(p))
164
+ }
165
+ }
166
+ }
167
+ db.exec('COMMIT')
168
+ } finally {
169
+ db.close()
170
+ }
171
+ }
172
+
173
+ /** zcode manifest(F21 磁盘真实形态:无 sessionFile 键)。 */
174
+ function writeZcodeManifest(
175
+ saId: string,
176
+ opts: { sessionRef?: Record<string, string>; omitEngineHandle?: boolean; engine?: string | true } = {},
177
+ ): void {
178
+ const recordsDir = join(agentDir, 'subagents', 'proj-slug', 'records')
179
+ mkdirSync(recordsDir, { recursive: true })
180
+ const m: Record<string, unknown> = {
181
+ id: saId,
182
+ rootSessionId: ROOT_SESSION,
183
+ agentName: 'worker',
184
+ task: 'fixture task',
185
+ status: 'running',
186
+ }
187
+ m.engine = opts.engine === undefined ? 'zcode' : opts.engine
188
+ if (!opts.omitEngineHandle) {
189
+ m.engineHandle = {
190
+ sessionRef: opts.sessionRef ?? { sessionId: SESS_B, dbPath },
191
+ poolKey: 'shared',
192
+ }
193
+ }
194
+ writeFileSync(join(recordsDir, `${saId}.json`), JSON.stringify(m), 'utf8')
195
+ }
196
+
197
+ /** subagent-record custom entry 行(U7 entry-anchor.test 同款形状)。 */
198
+ function recordLine(entryId: string, saId: string, sessionRef: Record<string, string>): string {
199
+ return JSON.stringify({
200
+ type: 'custom',
201
+ customType: 'subagent-record',
202
+ id: entryId,
203
+ parentId: null,
204
+ data: { v: 1, id: saId, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' } },
205
+ })
206
+ }
207
+
208
+ /**
209
+ * pi 形态的 subagent-record custom entry 行(回归 A1 instrument):engine 键缺省 +
210
+ * sessionRef 无 dbPath——subagent-core record-entry.ts 的 pi 投影形态(engineHandle
211
+ * 可选、sessionRef 整体透传不枚举内部键;最小可信子集,跨包漂移由 entry-anchor.test 守卫)。
212
+ */
213
+ function piRecordLine(entryId: string, saId: string): string {
214
+ return JSON.stringify({
215
+ type: 'custom',
216
+ customType: 'subagent-record',
217
+ id: entryId,
218
+ parentId: null,
219
+ data: {
220
+ v: 1,
221
+ id: saId,
222
+ agent: 'pi-agent',
223
+ task: 'pi task',
224
+ slug: 'pi-sub',
225
+ status: 'running',
226
+ mode: 'subagent',
227
+ startedAt: BASE_MS,
228
+ rootSessionId: ROOT_SESSION,
229
+ parentRecordId: null,
230
+ depth: 0,
231
+ turns: 0,
232
+ totalTokens: 0,
233
+ eventLog: [],
234
+ displayItems: [],
235
+ engineHandle: { sessionRef: { sessionId: SESS_A }, poolKey: 'shared' },
236
+ },
237
+ })
238
+ }
239
+
240
+ const HEADER_LINE = JSON.stringify({ type: 'session', id: 'main-session-1', cwd: '/proj' })
241
+ const USER_LINE = JSON.stringify({
242
+ type: 'message',
243
+ id: 'u1',
244
+ parentId: null,
245
+ message: { role: 'user', content: [{ type: 'text', text: 'dispatch subagent' }] },
246
+ })
247
+
248
+ /** liveSessionDir 内的主 session 文件(entry 兜底候选)。 */
249
+ function writeLiveMainSession(lines: string[]): void {
250
+ writeFileSync(join(liveDir, 'main-session.jsonl'), [HEADER_LINE, USER_LINE, ...lines].join('\n') + '\n', 'utf8')
251
+ }
252
+
253
+ /** main 默认根的 root session(family/rootSessionId 视图的 byId 素材)。 */
254
+ function writeRootSessionFile(): void {
255
+ const sessionsDir = join(agentDir, 'sessions')
256
+ mkdirSync(sessionsDir, { recursive: true })
257
+ writeFileSync(
258
+ join(sessionsDir, `1700000000000_${ROOT_SESSION}.jsonl`),
259
+ JSON.stringify({ type: 'session', id: ROOT_SESSION, cwd: '/proj' }) + '\n',
260
+ 'utf8',
261
+ )
262
+ }
263
+
264
+ /** pi manifest + pi subagent session 文件(pi 穿插断言用;engine 缺省 = pi 存量形态)。 */
265
+ function writePiFixture(): void {
266
+ const subDir = join(agentDir, 'subagents', 'pi-slug', 'sessions')
267
+ mkdirSync(subDir, { recursive: true })
268
+ const piSession = join(subDir, '019e6c96-pi-sub-agent-00000000004a4.jsonl')
269
+ writeFileSync(
270
+ piSession,
271
+ [
272
+ JSON.stringify({ type: 'session', id: '019e6c96-pi-sub-agent-00000000004a4', cwd: '/proj' }),
273
+ JSON.stringify({ type: 'message', id: 'p1', parentId: null, message: { role: 'user', content: [{ type: 'text', text: 'pi task' }] } }),
274
+ JSON.stringify({ type: 'message', id: 'p2', parentId: 'p1', message: { role: 'assistant', content: [{ type: 'text', text: 'PI-SENTINEL 正文内容' }] } }),
275
+ ].join('\n') + '\n',
276
+ 'utf8',
277
+ )
278
+ const recordsDir = join(agentDir, 'subagents', 'pi-slug', 'records')
279
+ mkdirSync(recordsDir, { recursive: true })
280
+ writeFileSync(
281
+ join(recordsDir, 'sa-pi-1.json'),
282
+ JSON.stringify({
283
+ id: 'sa-pi-1',
284
+ rootSessionId: ROOT_SESSION,
285
+ agentName: 'pi-agent',
286
+ sessionFile: piSession,
287
+ slug: 'pi-sub',
288
+ status: 'completed',
289
+ }),
290
+ 'utf8',
291
+ )
292
+ }
293
+
294
+ const signals = () => ({ agentDir, liveSessionDir: liveDir })
295
+
296
+ function logCallsWithReason(reason: string): number {
297
+ return appendEntries.mock.calls.filter(
298
+ ([ct, d]) => ct === 'session-reader:log' && (d as { data?: { reason?: string } })?.data?.reason === reason,
299
+ ).length
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // 变体 M(manifest 在场,主路径)+ 场景 2 动作覆盖
304
+ // ---------------------------------------------------------------------------
305
+
306
+ describe('变体 M:manifest 主路径(instrument:manifest 锚与 entry 锚不同 session)', () => {
307
+ beforeEach(() => {
308
+ createZcodeDb(dbPath, '0.16.5', [sessARows(), sessBRows()])
309
+ // manifest 锚 → sess_bbb;liveSessionDir entry 锚 → sess_aaa:读到 ZBSENTINEL
310
+ // 而非 ZASENTINEL = 走了 manifest 主路径(entry 兜底未被进入的输出级证据)
311
+ writeZcodeManifest(SA_M)
312
+ writeLiveMainSession([recordLine('e1', SA_M, { sessionId: SESS_A, dbPath })])
313
+ writeRootSessionFile()
314
+ })
315
+
316
+ it('outline:读到 manifest 锚的 sess_bbb(ZBSENTINEL 在,ZASENTINEL 不在),两轮 T000/T001', async () => {
317
+ const r = await handleSessionRead({ action: 'outline', session: SA_M }, signals())
318
+ const text = r.content[0]?.text ?? ''
319
+ expect(text).toContain('ZBSENTINEL')
320
+ expect(text).not.toContain('ZASENTINEL')
321
+ expect(text).toContain('T000')
322
+ expect(text).toContain('T001')
323
+ // outline 的 isCompaction 恒 false 属已登记形态(§4 已接受代价)——无独立压缩 turn
324
+ expect(text).not.toContain('compaction')
325
+ })
326
+
327
+ it('result:单 id 纯正文 = sess_bbb 最终 assistant 文本(末轮,非仅通知本轮)', async () => {
328
+ const r = await handleSessionRead({ action: 'result', session: SA_M }, signals())
329
+ expect(r.content[0]?.text).toBe('第二轮 ZBSENTINEL 收尾')
330
+ })
331
+
332
+ it('result 截断指针:zcode 路由抑制 read 半边(库路径二进制不可 read),只留 session_read detail 可执行半边', async () => {
333
+ const r = await handleSessionRead({ action: 'result', session: SA_M, limit: 5 }, signals())
334
+ const text = r.content[0]?.text ?? ''
335
+ expect(text).toContain('[truncated 5 of')
336
+ // 可执行半边在场(错误 → 权威源 → 重试闭环)
337
+ expect(text).toContain(`session_read { action:"detail", session:"${SA_M}" }`)
338
+ // read 半边被抑制:不指库路径,也不出现 "full text: read" 形态
339
+ expect(text).not.toContain(dbPath)
340
+ expect(text).not.toContain('full text: read')
341
+ })
342
+
343
+ it('detail:toolCall 全配对(bash)+ compaction custom entry 形态(渲染现状锚定)', async () => {
344
+ const r = await handleSessionRead({ action: 'detail', session: SA_M, turns: 'T000-T002' }, signals())
345
+ const text = r.content[0]?.text ?? ''
346
+ expect(text).toContain('ZZQFIXTURE')
347
+ expect(text).toContain('bash')
348
+ expect(text).toContain('[custom:zcode-import:compaction]')
349
+ // A6 断言强化①:compaction custom entry 计数等式(fixture 恰 1 个压缩点)——
350
+ // 防「既不多」方向无防护(多插压缩点/重复渲染的实现在此失败)
351
+ expect((text.match(/\[custom:zcode-import:compaction\]/g) ?? []).length).toBe(1)
352
+ // A6 断言强化②:toolCall/toolResult 按 toolCallId 全配对(全文态 entries 直读——
353
+ // 摘要态 ToolResultSummaryEntry 不携带 toolCallId,无法配对)。converter 契约
354
+ // 「未完成 tool 整对丢弃防 dangling」在此以集合配对相等 + 双向 0 dangling 锚定。
355
+ const full = await handleSessionRead(
356
+ { action: 'detail', session: SA_M, turns: 'T000-T002', includeToolResult: true },
357
+ signals(),
358
+ )
359
+ const entries = (full.details as { entries: Array<Record<string, unknown>> }).entries
360
+ const callIds = new Set<string>()
361
+ const resultIds = new Set<string>()
362
+ for (const e of entries) {
363
+ const msg = e.message as { role?: unknown; content?: unknown; toolCallId?: unknown } | undefined
364
+ if (msg === undefined) continue
365
+ if (Array.isArray(msg.content)) {
366
+ for (const b of msg.content) {
367
+ if (b && typeof b === 'object' && (b as Record<string, unknown>).type === 'toolCall') {
368
+ const id = (b as Record<string, unknown>).id
369
+ if (typeof id === 'string') callIds.add(id)
370
+ }
371
+ }
372
+ }
373
+ if (msg.role === 'toolResult' && typeof msg.toolCallId === 'string') {
374
+ resultIds.add(msg.toolCallId)
375
+ }
376
+ }
377
+ expect(resultIds).toEqual(callIds) // 配对相等 = 无 toolCall 缺 result、无 result 悬空
378
+ expect(callIds.size).toBe(1) // fixture 恰 1 对(tc-1)——非平凡性锚(空集恒配对不构成证明)
379
+ })
380
+
381
+ it('search:ZZQFIXTURE 命中(canonical entries 走既有检索管线)', async () => {
382
+ const r = await handleSessionRead({ action: 'search', session: SA_M, pattern: 'ZZQFIXTURE' }, signals())
383
+ const text = r.content[0]?.text ?? ''
384
+ expect(text).toContain('ZZQFIXTURE')
385
+ expect(text).toContain('1 hit')
386
+ })
387
+ })
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // 变体 E(manifest 缺位 → entry 兜底取末条)
391
+ // ---------------------------------------------------------------------------
392
+
393
+ describe('变体 E:manifest 缺位 → entry 兜底(liveSessionDir 内取文件顺序末条)', () => {
394
+ beforeEach(() => {
395
+ createZcodeDb(dbPath, '0.16.5', [sessARows(), sessBRows()])
396
+ // ≥2 条同 sa-id entry:首条 sess_aaa、末条 sess_bbb(F16 每轮覆写)——
397
+ // 取首条/合并的实现会输出 sess_aaa 行集,必失败
398
+ writeLiveMainSession([
399
+ recordLine('e1', SA_E, { sessionId: SESS_A, dbPath }),
400
+ recordLine('e2', SA_E, { sessionId: SESS_B, dbPath }),
401
+ ])
402
+ })
403
+
404
+ it('result:兜底命中末条 → sess_bbb 正文(ZBSENTINEL 在,ZASENTINEL 不在)', async () => {
405
+ const r = await handleSessionRead({ action: 'result', session: SA_E }, signals())
406
+ expect(r.content[0]?.text).toBe('第二轮 ZBSENTINEL 收尾')
407
+ expect(r.content[0]?.text).not.toContain('ZASENTINEL')
408
+ })
409
+
410
+ it('兜底越界:liveSessionDir 内无该 sa-id 的任何 entry → zcode_record_not_found + 指引', async () => {
411
+ await expect(
412
+ handleSessionRead({ action: 'result', session: 'sa-nowhere-1' }, signals()),
413
+ ).rejects.toThrow(/zcode_record_not_found[\s\S]*👉/)
414
+ })
415
+ })
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // 场景 5:八类失败逐一断言(错误码 + 👉 指引 + ④ 顺序断言两种构造)
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe('场景 5 八类失败(§3.4 表逐行 + 检查顺序三段递进)', () => {
422
+ it('① manifest engine=zcode 但 engineHandle 整体缺席 → zcode_anchor_missing(导入对话框指引)', async () => {
423
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
424
+ writeZcodeManifest(SA_M, { omitEngineHandle: true })
425
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
426
+ /zcode_anchor_missing[\s\S]*导入/,
427
+ )
428
+ })
429
+
430
+ it('② entry 的 sessionRef 只留 sessionId、删 dbPath → zcode_anchor_missing + 结构化日志记「缺 dbPath」', async () => {
431
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
432
+ writeLiveMainSession([recordLine('e1', SA_M, { sessionId: SESS_B })])
433
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
434
+ 'zcode_anchor_missing',
435
+ )
436
+ // 归因进结构化日志不进 LLM 可见面(§3.4):缺失键名可区分(missing-dbPath)
437
+ expect(logCallsWithReason('missing-dbPath')).toBeGreaterThanOrEqual(1)
438
+ })
439
+
440
+ it('③ manifest 删除 + liveSessionDir 内无该 sa-id entry → zcode_record_not_found(三动作指引)', async () => {
441
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
442
+ writeLiveMainSession([recordLine('e1', 'sa-other-1', { sessionId: SESS_B, dbPath })])
443
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
444
+ /zcode_record_not_found[\s\S]*派发的那个会话[\s\S]*family[\s\S]*换一个已完成/,
445
+ )
446
+ })
447
+
448
+ it('③b pi 形态 entry(engine 缺省、sessionRef 无 dbPath)在场 + manifest 缺位 → zcode_record_not_found(engine 判别防误归因 anchor_missing)', async () => {
449
+ // 回归 A1:pi record register() 先于 manifest settle 的窗口——manifest 缺位 +
450
+ // entry 为 pi 形态。pi record 不是「旧版本 zcode 产物」(导入对话框指引不适用),
451
+ // 必须落 zcode_record_not_found;误归因 missing-dbPath → zcode_anchor_missing 即红。
452
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
453
+ writeLiveMainSession([piRecordLine('e-pi-1', SA_M)])
454
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
455
+ /zcode_record_not_found/,
456
+ )
457
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.not.toThrow(
458
+ /zcode_anchor_missing/,
459
+ )
460
+ })
461
+
462
+ it('④a 顺序断言(构造一):dbPath 集合外且文件真实存在 → zcode_db_path_forbidden', async () => {
463
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
464
+ const evilPath = join(root, 'evil-whitelist-probe.sqlite')
465
+ writeFileSync(evilPath, 'not a real db', 'utf8') // 存在性为真 → 走到路径段
466
+ writeZcodeManifest(SA_M, { sessionRef: { sessionId: SESS_B, dbPath: evilPath } })
467
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
468
+ /zcode_db_path_forbidden[\s\S]*👉/,
469
+ )
470
+ expect(existsSync(evilPath)).toBe(true)
471
+ })
472
+
473
+ it('④b 顺序断言(构造二):同一集合外路径但文件不存在 → zcode_db_unreadable(存在性先于路径)', async () => {
474
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
475
+ const missingEvil = join(root, 'evil-whitelist-probe-missing.sqlite')
476
+ writeZcodeManifest(SA_M, { sessionRef: { sessionId: SESS_B, dbPath: missingEvil } })
477
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
478
+ /zcode_db_unreadable[\s\S]*👉/,
479
+ )
480
+ })
481
+
482
+ it('⑤ 白名单形态路径但库文件缺席 → zcode_db_unreadable(含 -wal 告诫指引)', async () => {
483
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
484
+ const missingInLayout = join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1), 'missing.sqlite')
485
+ writeZcodeManifest(SA_M, { sessionRef: { sessionId: SESS_B, dbPath: missingInLayout } })
486
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
487
+ /zcode_db_unreadable[\s\S]*👉[\s\S]*-wal/,
488
+ )
489
+ })
490
+
491
+ it('⑥ schema_migration 版本超出已知集 → zcode_schema_drift(升级 taiji 指引)', async () => {
492
+ createZcodeDb(dbPath, '9.9.9', [sessBRows()])
493
+ writeZcodeManifest(SA_M)
494
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
495
+ /zcode_schema_drift[\s\S]*9\.9\.9[\s\S]*👉/,
496
+ )
497
+ })
498
+
499
+ it('⑦ 传入 sess_ 形态 id → zcode_param_invalid(换完整 sa- id 指引)', async () => {
500
+ await expect(
501
+ handleSessionRead({ action: 'result', session: SESS_B }, signals()),
502
+ ).rejects.toThrow(/zcode_param_invalid[\s\S]*👉[\s\S]*sa-/)
503
+ })
504
+
505
+ it('⑧ 锚可解析 + 库可开 + schema 兼容,但库内 session 行已删 → zcode_session_not_found', async () => {
506
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
507
+ writeZcodeManifest(SA_M)
508
+ // 模拟 zcode 侧 GC:删 sess_bbb 的全部行(db 文件与 schema 保留)
509
+ const db = new DatabaseSync(dbPath)
510
+ try {
511
+ db.exec("DELETE FROM part WHERE session_id = 'sess_bbb'")
512
+ db.exec("DELETE FROM message WHERE session_id = 'sess_bbb'")
513
+ db.exec("DELETE FROM session WHERE id = 'sess_bbb'")
514
+ } finally {
515
+ db.close()
516
+ }
517
+ await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
518
+ /zcode_session_not_found[\s\S]*👉[\s\S]*原 sa-id[\s\S]*family/,
519
+ )
520
+ })
521
+ })
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // 驱动探测失败的错误面契约(跨包漂移守卫 + L4 包装形态映射)
525
+ // ---------------------------------------------------------------------------
526
+
527
+ describe('驱动探测失败错误面:跨包契约 + L4 包装形态映射', () => {
528
+ it('跨包契约:SQLITE_DRIVER_UNSUPPORTED_MARK 子串在 zcode-session-source sqlite-driver 源文内(漂移即红)', () => {
529
+ // 驱动探测错误无专用错误类型(该包不导出子类),reader 按消息特征识别——mark 与
530
+ // sqlite-driver 源内错误字面量的绑定由本测试机器锚定(entry-anchor.test.ts 协议
531
+ // 字面量跨包漂移守卫同范式):源文措辞变更不再含 mark → host_unsupported 面死亡,红。
532
+ const source = readFileSync(
533
+ new URL('../../../../../packages/zcode-session-source/src/sqlite-driver.ts', import.meta.url),
534
+ 'utf8',
535
+ )
536
+ expect(source).toContain(SQLITE_DRIVER_UNSUPPORTED_MARK)
537
+ })
538
+
539
+ it('L4 包装形态映射:SqliteUnreadableError(message 含 mark,last failure 并入)→ host_unsupported(R1 回归锚)', () => {
540
+ // 生产路径上驱动探测错误恒经 recovery L4 包装(last failure 以字符串并入
541
+ // SqliteUnreadableError.message,mark 随之存活)——直接构造该形态,锁定
542
+ // instanceof 分支内的二次判别(分支顺序错误 → 误落 db_unreadable,本用例红)。
543
+ const wrapped = new SqliteUnreadableError(
544
+ ['L1-direct', 'L2-immutable'],
545
+ 'zcode session db unreadable (recovery ladder exhausted): /tmp/none.sqlite — ' +
546
+ `last failure: 当前 node 运行时${SQLITE_DRIVER_UNSUPPORTED_MARK}(需 >=22.13),且宿主非 bun 运行时`,
547
+ )
548
+ const msg = zcodeReadErrorMessage(wrapped, agentDir)
549
+ expect(msg).toContain('zcode_host_unsupported')
550
+ expect(msg).not.toContain('zcode_db_unreadable')
551
+ })
552
+
553
+ it('无 mark 的 SqliteUnreadableError 维持 db_unreadable 面(对照:attempted 链进 detail)', () => {
554
+ const plain = new SqliteUnreadableError(
555
+ ['L1-direct'],
556
+ 'zcode session db unreadable (recovery ladder exhausted): /tmp/none.sqlite — last failure: file is not a database',
557
+ )
558
+ const msg = zcodeReadErrorMessage(plain, agentDir)
559
+ expect(msg).toContain('zcode_db_unreadable')
560
+ expect(msg).toContain('L1-direct')
561
+ expect(msg).not.toContain('zcode_host_unsupported')
562
+ })
563
+
564
+ it('跨包契约:ZCODE_DB_MISSING_MARK 子串在 zcode-session-source sqlite-access 源文内(漂移即红)', () => {
565
+ // 库文件缺失(openZcodeSessionDb 开头 existsSync 失败)无专用错误类型(抛普通
566
+ // Error),reader 按消息特征识别(SQLITE_DRIVER_UNSUPPORTED_MARK 契约测试同范式):
567
+ // sqlite-access 源文措辞变更不再含 mark → db_unreadable 面死亡、TOCTOU 缺失误标
568
+ // schema_drift,红。
569
+ const source = readFileSync(
570
+ new URL('../../../../../packages/zcode-session-source/src/sqlite-access.ts', import.meta.url),
571
+ 'utf8',
572
+ )
573
+ expect(source).toContain(ZCODE_DB_MISSING_MARK)
574
+ })
575
+
576
+ it('TOCTOU 窗口:开库期库文件缺失普通 Error → db_unreadable 面(不误标 schema_drift,§3.4 第 4 行)', () => {
577
+ // 白名单闸 existsSync 通过后、openZcodeSessionDb 开库前文件被删的窗口:sqlite-access
578
+ // 抛普通 Error「db 文件不存在:<dbPath>」——落 db_unreadable(环境恢复指引),编程
579
+ // 错误兜底(schema_drift)只接真正的查询期 schema 漂移域。
580
+ const msg = zcodeReadErrorMessage(new Error(`db 文件不存在:/tmp/fixture/db.sqlite`), agentDir)
581
+ expect(msg).toContain('zcode_db_unreadable')
582
+ expect(msg).toContain('db 文件不存在:/tmp/fixture/db.sqlite')
583
+ expect(msg).toContain('👉')
584
+ expect(msg).not.toContain('zcode_schema_drift')
585
+ })
586
+
587
+ it('对照:不含缺失 mark 的普通 Error 仍落 schema_drift 兜底(编程错误域语义不扩)', () => {
588
+ const msg = zcodeReadErrorMessage(new Error('TypeError: cannot read properties of undefined'), agentDir)
589
+ expect(msg).toContain('zcode_schema_drift')
590
+ expect(msg).not.toContain('zcode_db_unreadable')
591
+ })
592
+ })
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // family 接线(buildFamilyFromFs zcode 分支 + sa-id family 路由)
596
+ // ---------------------------------------------------------------------------
597
+
598
+ describe('family:zcode 节点接线(D5-1 两态)与 sa-id family 路由', () => {
599
+ beforeEach(() => {
600
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
601
+ writeZcodeManifest(SA_M)
602
+ writeRootSessionFile()
603
+ })
604
+
605
+ it('sa-id family:zcode 路由 → rootSessionId 视图,subagents 含 zcode 节点且 cleanedUp=false', async () => {
606
+ const r = await handleSessionRead({ action: 'family', session: SA_M }, signals())
607
+ const text = r.content[0]?.text ?? ''
608
+ expect(text).toContain(SA_M)
609
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
610
+ const node = family.subagents.find((s) => s.sessionId === SA_M)
611
+ expect(node?.cleanedUp).toBe(false) // 库可达态(D5-1:锚可解析 ∧ 库文件存在)
612
+ })
613
+
614
+ it('sa-id family recursive=true:rootSessionId 反查回填 fileName → 树 main root 填真实 session 路径(MF-1 链路)', async () => {
615
+ const r = await handleSessionRead({ action: 'family', session: SA_M, recursive: true }, signals())
616
+ const tree = (
617
+ r.details as { tree: { root: { type: string; sessionId: string; sessionFile?: string } } }
618
+ ).tree
619
+ expect(tree.root.type).toBe('main')
620
+ expect(tree.root.sessionId).toBe(ROOT_SESSION)
621
+ // 反查命中默认根的 root session 文件(writeRootSessionFile 落的路径),非空串占位
622
+ expect(tree.root.sessionFile).toBe(join(agentDir, 'sessions', `1700000000000_${ROOT_SESSION}.jsonl`))
623
+ })
624
+
625
+ it('rootSessionId family:buildFamilyFromFs 接线后 zcode 节点进列表(真实 family action 输出)', async () => {
626
+ const r = await handleSessionRead({ action: 'family', session: ROOT_SESSION }, signals())
627
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
628
+ expect(family.subagents.map((s) => s.sessionId)).toContain(SA_M)
629
+ })
630
+
631
+ it('库不存在态:dbPath 改指白名单布局内缺席路径 → cleanedUp=true(GC 语义,不报错)', async () => {
632
+ writeZcodeManifest(SA_M, {
633
+ sessionRef: { sessionId: SESS_B, dbPath: join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1), 'gone.sqlite') },
634
+ })
635
+ const r = await handleSessionRead({ action: 'family', session: ROOT_SESSION }, signals())
636
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
637
+ expect(family.subagents.find((s) => s.sessionId === SA_M)?.cleanedUp).toBe(true)
638
+ })
639
+
640
+ it('反向断言:库文件在但库内 session 行被删 → family 仍 cleanedUp=false(列表视图不开库的分工)', async () => {
641
+ const db = new DatabaseSync(dbPath)
642
+ try {
643
+ db.exec("DELETE FROM part WHERE session_id = 'sess_bbb'")
644
+ db.exec("DELETE FROM message WHERE session_id = 'sess_bbb'")
645
+ db.exec("DELETE FROM session WHERE id = 'sess_bbb'")
646
+ } finally {
647
+ db.close()
648
+ }
649
+ const r = await handleSessionRead({ action: 'family', session: ROOT_SESSION }, signals())
650
+ const family = r.details as { subagents: Array<{ sessionId: string; cleanedUp?: boolean }> }
651
+ expect(family.subagents.find((s) => s.sessionId === SA_M)?.cleanedUp).toBe(false)
652
+ })
653
+ })
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // pi 穿插不受影响(场景 3 抽样:zcode 失败调用穿插前后 pi 输出不变)
657
+ // ---------------------------------------------------------------------------
658
+
659
+ describe('pi 穿插不受影响:同 fixture 内 pi 会话读取与 zcode 调用互不干扰', () => {
660
+ beforeEach(() => {
661
+ createZcodeDb(dbPath, '0.16.5', [sessBRows()])
662
+ writeZcodeManifest(SA_M)
663
+ writePiFixture()
664
+ })
665
+
666
+ it('result:pi sa-id 正文穿插 zcode 失败调用前后一致(PI-SENTINEL 恒在)', async () => {
667
+ // zcode 调用先失败(锚缺位形态)
668
+ writeZcodeManifest('sa-broken-1', { omitEngineHandle: true })
669
+ await expect(handleSessionRead({ action: 'result', session: 'sa-broken-1' }, signals())).rejects.toThrow(
670
+ 'zcode_anchor_missing',
671
+ )
672
+ const pi = await handleSessionRead({ action: 'result', session: 'sa-pi-1' }, signals())
673
+ expect(pi.content[0]?.text).toContain('PI-SENTINEL 正文内容')
674
+ // 再穿插一次 zcode 成功调用,pi 输出仍一致
675
+ const piAgain = await handleSessionRead({ action: 'result', session: 'sa-pi-1' }, signals())
676
+ expect(piAgain.content[0]?.text).toBe(pi.content[0]?.text)
677
+ })
678
+
679
+ it('uuid 形态(resolveSessionId 形态③)与 pi sa-id 现路径零变化', async () => {
680
+ const pi = await handleSessionRead({ action: 'result', session: 'sa-pi-1' }, { agentDir })
681
+ expect(pi.content[0]?.text).toContain('PI-SENTINEL')
682
+ })
683
+ })
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // D4 schema 零变化(快照逐字节对照 + 合法形态冒烟)
687
+ // ---------------------------------------------------------------------------
688
+
689
+ /** `session_read` 输入 schema 的 JSON 序列化快照(U9 改动前从基线实现抓取——D4「schema 零变化」的逐字节锚)。 */
690
+ const SCHEMA_SNAPSHOT =
691
+ '{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["find","f'
692
+ + 'amily","outline","expand","detail","search","export","extract","workflow","result","doctor"],"de'
693
+ + 'scription":"Action to perform: find (locate session), family (fork/subagent/workflow relations; '
694
+ + 'recursive=true returns nested execution tree), outline (turn-level overview), expand (single-tur'
695
+ + 'n entries), detail (full text of turns), search (full-text grep, single session or cross-session'
696
+ + ' over a comma-separated id list), export (materialize to file), extract (pull user messages / co'
697
+ + 'mmands / files / commits / tool results by type), workflow (workflow run overview: status/budget'
698
+ + '/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/d'
699
+ + 'etail), result (fetch a subagent session final result text — same content as its completion noti'
700
+ + 'ce; session = single id or comma-separated batch of at most 10, optional limit caps chars per it'
701
+ + 'em), doctor (show detected host environment and session-root diagnostics)."},"session":{"type":"'
702
+ + 'string","description":"Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, preci'
703
+ + 'se lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail'
704
+ + '/search/export/extract/workflow/result. result also accepts a comma-separated list of up to 10 i'
705
+ + 'ds. search also accepts a comma-separated list of up to 10 full ids (from find output) for cross'
706
+ + '-session search. # prefix auto-stripped."},"query":{"type":"string","description":"find action: '
707
+ + 'uuid fragment / filename / name keyword / \\"recent\\" (returns most recent N)."},"turns":{"type'
708
+ + '":"string","description":"detail/extract action: turn range, \\"T013-T015\\" or \\"T013\\"."},"t'
709
+ + 'urn":{"type":"string","description":"expand action: single turn, \\"T013\\"."},"pattern":{"type"'
710
+ + ':"string","description":"search action: substring or regex."},"scope":{"type":"string","enum":["'
711
+ + 'all","user","assistant","toolResult"],"description":"search action: scope filter. Default all."}'
712
+ + ',"format":{"type":"string","enum":["outline","full","family"],"description":"export action: mate'
713
+ + 'rialized form. Default outline."},"includeToolResult":{"type":"boolean","description":"detail/ex'
714
+ + 'port: include toolResult full text. Default false (omitted as noise)."},"includeThinking":{"type'
715
+ + '":"boolean","description":"detail: include thinking blocks. Default false (omitted as noise)."},'
716
+ + '"allBranches":{"type":"boolean","description":"outline (and export\'s outline section): include '
717
+ + 'abandoned side-branches. Not supported by family. Default false."},"granularity":{"type":"string'
718
+ + '","enum":["turn","entry"],"description":"outline: turn-level or entry-flat. Default turn."},"cwd'
719
+ + '":{"type":"string","description":"find: filter by cwd. Optional."},"source":{"type":"string","en'
720
+ + 'um":["main","subagent"],"description":"find and session-resolving actions: filter by source. \\"'
721
+ + 'main\\" = sessions/, \\"subagent\\" = subagents/. Default both (merged)."},"limit":{"type":"numb'
722
+ + 'er","minimum":1,"description":"find/search: max results. Default 20. result: max chars per item,'
723
+ + ' default 8000 (overlong text truncated with a pointer to the full file)."},"what":{"type":"strin'
724
+ + 'g","enum":["user-messages","commands","files","commits","tool-results"],"description":"extract a'
725
+ + 'ction: what to extract (required for extract)."},"tool":{"type":"string","description":"extract '
726
+ + 'action: filter commands/tool-results by tool name (e.g. \\"bash\\")."},"runId":{"type":"string",'
727
+ + '"description":"workflow action: focus a single run by runId (disambiguate multiple runs). Omit t'
728
+ + 'o see all run overviews."},"recursive":{"type":"boolean","description":"family action: return ne'
729
+ + 'sted execution tree (arbitrary-depth subagent↔workflow-call nesting, precise parentRecordId chai'
730
+ + 'n with flat-fallback for legacy records). Default false (flat family)."},"includeSubagents":{"ty'
731
+ + 'pe":"boolean","description":"doctor action: also scan the subagent session root (adds its file c'
732
+ + 'ount). Default false (path and existence only)."}}}';
733
+
734
+ describe('session_read schema 快照(D4:不加任何工具参数,逐字节一致)', () => {
735
+ it('TypeBox schema JSON 序列化与基线快照逐字节一致', () => {
736
+ const registerTool = vi.fn()
737
+ const pi = { registerTool, registerCommand: vi.fn(), addAutocompleteProvider: vi.fn(), on: vi.fn() }
738
+ ;(sessionReaderExtension as unknown as (p: unknown) => void)(pi)
739
+ const toolDef = registerTool.mock.calls[0][0] as { parameters: unknown }
740
+ expect(JSON.stringify(toolDef.parameters)).toBe(SCHEMA_SNAPSHOT)
741
+ })
742
+
743
+ it('既有合法调用形态仍通过 schema 校验(无新增必填参数)', () => {
744
+ const registerTool = vi.fn()
745
+ const pi = { registerTool, registerCommand: vi.fn(), addAutocompleteProvider: vi.fn(), on: vi.fn() }
746
+ ;(sessionReaderExtension as unknown as (p: unknown) => void)(pi)
747
+ const schema = (registerTool.mock.calls[0][0] as { parameters: unknown }).parameters
748
+ expect(Check(schema, { action: 'result', session: 'sa-xxx' })).toBe(true)
749
+ expect(Check(schema, { action: 'outline', session: 'e6c96' })).toBe(true)
750
+ })
751
+ })