@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.
- package/package.json +4 -4
- package/src/__tests__/entry-anchor.test.ts +704 -324
- package/src/__tests__/family.test.ts +588 -312
- package/src/__tests__/{cross-package-subagent-core.test.ts → path-encoding.test.ts} +194 -30
- package/src/__tests__/roots-merged.test.ts +4919 -0
- package/src/__tests__/roots.test.ts +1986 -432
- package/src/__tests__/tool-handler.test.ts +872 -3216
- package/src/__tests__/tree.test.ts +738 -101
- package/src/__tests__/archive-ledger.test.ts +0 -447
- package/src/__tests__/execution-tree.test.ts +0 -898
- package/src/__tests__/family-zcode.test.ts +0 -265
- package/src/__tests__/find.test.ts +0 -802
- package/src/__tests__/rebuild-manifest-dual-write.test.ts +0 -188
- package/src/__tests__/render.test.ts +0 -481
- package/src/__tests__/result.test.ts +0 -347
- package/src/__tests__/subagents.test.ts +0 -668
- package/src/__tests__/summary.test.ts +0 -223
- package/src/__tests__/turns.test.ts +0 -144
- package/src/__tests__/zcode-manifest.test.ts +0 -372
- package/src/__tests__/zcode-routing.test.ts +0 -772
|
@@ -1,3347 +1,1003 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
179
|
-
expect(
|
|
180
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
|
|
1965
|
-
|
|
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
|
-
|
|
2028
|
-
|
|
2029
|
-
const
|
|
2030
|
-
|
|
2031
|
-
|
|
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
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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
|
-
|
|
2074
|
-
|
|
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
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
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
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
-
|
|
2119
|
-
|
|
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
|
-
|
|
2123
|
-
|
|
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
|
-
|
|
2126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
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
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
-
|
|
2248
|
-
const { agentDir, signals } = await buildFourRootFixture()
|
|
262
|
+
const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
|
|
2249
263
|
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
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
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
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
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
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
|
-
|
|
2316
|
-
|
|
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
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
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
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
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
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
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
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
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
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
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
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
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
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
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
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
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
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
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
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
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
|
-
|
|
2671
|
-
|
|
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
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
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
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
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
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
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
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
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
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
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
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
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
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
-
|
|
2827
|
-
|
|
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
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
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
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
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
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
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
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
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
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
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
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
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
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
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
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
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
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
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
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
:
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
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
|
-
|
|
3083
|
-
|
|
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
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
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
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
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
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
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
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
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
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
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
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
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
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
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
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
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
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
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
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
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
|
+
});
|