@zhushanwen/pi-session-reader 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/package.json +6 -3
- package/src/__tests__/entry-anchor.test.ts +370 -0
- package/src/__tests__/execution-tree.test.ts +12 -12
- package/src/__tests__/family-zcode.test.ts +255 -0
- package/src/__tests__/family.test.ts +1 -1
- package/src/__tests__/find.test.ts +32 -49
- package/src/__tests__/fixtures.ts +15 -0
- package/src/__tests__/parser.test.ts +37 -13
- package/src/__tests__/render.test.ts +22 -19
- package/src/__tests__/result.test.ts +2 -2
- package/src/__tests__/roots.test.ts +32 -34
- package/src/__tests__/subagents.test.ts +131 -108
- package/src/__tests__/tool-handler.test.ts +506 -153
- package/src/__tests__/toolcall.test.ts +1 -1
- package/src/__tests__/tree.test.ts +1 -1
- package/src/__tests__/turns.test.ts +21 -20
- package/src/__tests__/workflow.test.ts +319 -23
- package/src/__tests__/zcode-manifest.test.ts +361 -0
- package/src/__tests__/zcode-routing.test.ts +751 -0
- package/src/__tests__/zcode-whitelist.test.ts +119 -0
- package/src/core/execution-tree.ts +3 -1
- package/src/core/family.ts +80 -1
- package/src/core/render.ts +1 -1
- package/src/core/toolcall.ts +2 -2
- package/src/core/tree.ts +1 -1
- package/src/core/turns.ts +1 -1
- package/src/core/workflow.ts +196 -6
- package/src/discovery/entry-anchor.ts +139 -0
- package/src/discovery/roots.ts +1 -2
- package/src/discovery/session-header.ts +30 -69
- package/src/discovery/subagents.ts +9 -2
- package/src/discovery/whitelist.ts +87 -0
- package/src/discovery/workflows.ts +232 -22
- package/src/discovery/zcode-manifest.ts +277 -0
- package/src/index.ts +7 -0
- package/src/result-action.ts +14 -5
- package/src/search-across.ts +1 -1
- package/src/tool-format.ts +455 -0
- package/src/tool-handler.ts +400 -344
- package/src/zcode-anchor-classify.ts +74 -0
- package/src/__tests__/real-data.ts +0 -96
- package/src/core/parser.ts +0 -167
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SessionReadParams,
|
|
10
10
|
type SessionReadSignals,
|
|
11
11
|
} from '../tool-handler.js'
|
|
12
|
+
import { setPiHandle } from '@zhushanwen/pi-extension-logger'
|
|
12
13
|
import { type DoctorDetails } from '../doctor.js'
|
|
13
14
|
import { levenshtein } from '../no-match.js'
|
|
14
15
|
import {
|
|
@@ -17,22 +18,11 @@ import {
|
|
|
17
18
|
searchAcrossSessions,
|
|
18
19
|
} from '../search-across.js'
|
|
19
20
|
import { renderExtractItems } from '../extract.js'
|
|
20
|
-
import { listRecordManifests } from '../discovery/subagents.js'
|
|
21
21
|
import type { SessionMetadataProvider } from '../discovery/find.js'
|
|
22
|
-
import {
|
|
23
|
-
REAL_AGENT_DIR as REAL,
|
|
24
|
-
E6,
|
|
25
|
-
FAM,
|
|
26
|
-
HAS_E6,
|
|
27
|
-
HAS_REAL,
|
|
28
|
-
HAS_REAL_SUBAGENTS_DIR,
|
|
29
|
-
hasAnyRealSession,
|
|
30
|
-
hasRealSession,
|
|
31
|
-
} from './real-data.js'
|
|
22
|
+
import { FIX_E6 } from './fixtures.js'
|
|
32
23
|
|
|
33
24
|
// fs-guard:TC6 的「~ 前缀展开」语义需要 homedir,但真实 homedir 根在白名单外(禁止向用户
|
|
34
|
-
// 主目录散落文件)。vi.mock 把 homedir 指到用例内 mkdtemp 的 tmp;fakeHome
|
|
35
|
-
// real-data.ts 模块初始化时推导 REAL_AGENT_DIR 不受影响(模块初始化先于任何用例执行)。
|
|
25
|
+
// 主目录散落文件)。vi.mock 把 homedir 指到用例内 mkdtemp 的 tmp;fakeHome 未注入时回落真实值。
|
|
36
26
|
const homeState = vi.hoisted(() => ({ fakeHome: undefined as string | undefined }))
|
|
37
27
|
vi.mock('node:os', async (importOriginal) => {
|
|
38
28
|
const actual = await importOriginal<typeof import('node:os')>()
|
|
@@ -43,35 +33,104 @@ vi.mock('node:os', async (importOriginal) => {
|
|
|
43
33
|
* M3 tool-handler 集成测试。
|
|
44
34
|
*
|
|
45
35
|
* 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
|
|
46
|
-
*
|
|
36
|
+
* agentDir 全部指向 mkdtemp 自建自删的 fixture 目录(按断言逻辑构造的最小 session 文件,
|
|
37
|
+
* 不读本机真实用户数据目录),fixture 生命周期与各 describe 的 beforeEach/afterEach 绑定。
|
|
47
38
|
*
|
|
48
39
|
* 覆盖全 11 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价,
|
|
49
40
|
* 以及 u8 doctor / u9 归一化 / u10 find 分组 / u11 metadataProvider 缓存 / u12 跨会话检索(各见对应 describe)。
|
|
50
|
-
*
|
|
51
|
-
* 真实数据用例全部带 skipIf 守卫(CI 无本机 ~/.pi/agent → skip,不硬失败);
|
|
52
|
-
* renderExtractItems F9 截断是纯 fixture,无条件跑。
|
|
53
41
|
*/
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
43
|
+
describe('handleSessionRead', () => {
|
|
44
|
+
let dir: string
|
|
45
|
+
const SLUG = '--demo-cwd--'
|
|
46
|
+
// 家族 fixture 组:根 FAM → fork 子代 FORK(跨 cwd,parentSession 指 FAM 文件)
|
|
47
|
+
// → 隔代 subagent SUB2(rootSessionId=FORK)。前缀互异,断言按 id 精确区分层级。
|
|
48
|
+
const FAM = '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7'
|
|
49
|
+
const FORK = '019fe632-aaaa-7bbb-8ccc-00000000fe32'
|
|
50
|
+
const SUB2 = '019fe635-aaaa-7bbb-8ccc-00000000fe35'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 主 fixture session(FIX_E6):2 turn 的最小多形态 session——
|
|
54
|
+
* - T000 含 toolCall + toolResult(detail 摘要态断言),文本含 'plugin'(search 断言)
|
|
55
|
+
* - T001 纯文本收尾(outline 计数 / fragment 等价断言)
|
|
56
|
+
* 另加 1 条无关 session(id 不含 e6c96):find 片段全扫「多条候选恰一条命中」语义。
|
|
57
|
+
*/
|
|
58
|
+
async function writeMainFixtures(): Promise<void> {
|
|
59
|
+
const sessionDir = join(dir, 'sessions', SLUG)
|
|
60
|
+
await mkdir(sessionDir, { recursive: true })
|
|
61
|
+
const msg = (id: string, parentId: string, message: Record<string, unknown>): string =>
|
|
62
|
+
JSON.stringify({ type: 'message', id, parentId, message })
|
|
63
|
+
const e6Lines = [
|
|
64
|
+
JSON.stringify({ type: 'session', id: FIX_E6, cwd: '/demo' }),
|
|
65
|
+
msg(`${FIX_E6}-m0`, FIX_E6, {
|
|
66
|
+
role: 'user',
|
|
67
|
+
content: [{ type: 'text', text: '调研 plugin 架构怎么落地' }],
|
|
68
|
+
}),
|
|
69
|
+
msg(`${FIX_E6}-m1`, `${FIX_E6}-m0`, {
|
|
70
|
+
role: 'assistant',
|
|
71
|
+
content: [
|
|
72
|
+
{ type: 'text', text: '先看现有目录结构' },
|
|
73
|
+
{ type: 'toolCall', id: 'tc-e6-1', name: 'bash', arguments: { command: 'ls src' } },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
msg(`${FIX_E6}-m2`, `${FIX_E6}-m1`, {
|
|
77
|
+
role: 'toolResult',
|
|
78
|
+
toolName: 'bash',
|
|
79
|
+
toolCallId: 'tc-e6-1',
|
|
80
|
+
content: [{ type: 'text', text: 'src/plugin.ts' }],
|
|
81
|
+
}),
|
|
82
|
+
msg(`${FIX_E6}-m3`, `${FIX_E6}-m2`, {
|
|
83
|
+
role: 'user',
|
|
84
|
+
content: [{ type: 'text', text: '继续深入' }],
|
|
85
|
+
}),
|
|
86
|
+
msg(`${FIX_E6}-m4`, `${FIX_E6}-m3`, {
|
|
87
|
+
role: 'assistant',
|
|
88
|
+
content: [{ type: 'text', text: '完成,共两轮。' }],
|
|
89
|
+
}),
|
|
90
|
+
]
|
|
91
|
+
await writeFile(join(sessionDir, `${FIX_E6}.jsonl`), e6Lines.join('\n') + '\n')
|
|
92
|
+
await makeFixtureSession(dir, '019ffff-aaaa-bbbb-cccc-00000000ffff', '无关会话')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 家族 fixture:FAM 根 + FORK(跨 cwd fork)+ 挂在 FORK 下的隔代 subagent(manifest 建族)。 */
|
|
96
|
+
async function writeFamilyFixtures(): Promise<void> {
|
|
97
|
+
const famPath = join(dir, 'sessions', SLUG, `${FAM}.jsonl`)
|
|
98
|
+
await writeFile(famPath, JSON.stringify({ type: 'session', id: FAM, cwd: '/demo' }) + '\n')
|
|
99
|
+
const forkDir = join(dir, 'sessions', '--fork-cwd--')
|
|
100
|
+
await mkdir(forkDir, { recursive: true })
|
|
101
|
+
await writeFile(
|
|
102
|
+
join(forkDir, `${FORK}.jsonl`),
|
|
103
|
+
JSON.stringify({ type: 'session', id: FORK, cwd: '/fork', parentSession: famPath }) + '\n',
|
|
104
|
+
)
|
|
105
|
+
await makeFixtureSubagent(dir, 'sa-fe635', { realSessionId: SUB2, rootSessionId: FORK })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-main-'))
|
|
110
|
+
await writeMainFixtures()
|
|
111
|
+
})
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
114
|
+
})
|
|
115
|
+
|
|
58
116
|
it('1. find by uuid fragment returns matching session', async () => {
|
|
59
|
-
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir:
|
|
117
|
+
const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, { agentDir: dir })
|
|
60
118
|
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
61
119
|
expect(d.matches.some((m) => m.sessionId.startsWith('019e6c96'))).toBe(true)
|
|
62
120
|
expect(r.content[0]).toEqual({ type: 'text', text: expect.any(String) })
|
|
63
121
|
})
|
|
64
122
|
|
|
65
|
-
it('2. outline yields
|
|
66
|
-
const r = await handleSessionRead({ action: 'outline', session:
|
|
123
|
+
it('2. outline yields all turns within token budget (v2 O1: <=1500)', async () => {
|
|
124
|
+
const r = await handleSessionRead({ action: 'outline', session: FIX_E6 }, { agentDir: dir })
|
|
67
125
|
const d = r.details as { turns: unknown[]; tokenEstimate: number }
|
|
68
|
-
expect(d.turns.length).toBe(
|
|
69
|
-
// v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值
|
|
126
|
+
expect(d.turns.length).toBe(2)
|
|
127
|
+
// v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 1500(design §3.3 D4)
|
|
70
128
|
expect(d.tokenEstimate).toBeLessThanOrEqual(1500)
|
|
71
129
|
})
|
|
72
130
|
|
|
73
131
|
it('3. detail single turn returns toolResult summary by default (v2 O3)', async () => {
|
|
74
|
-
|
|
132
|
+
// fixture 的 T000 是含 toolCall + toolResult 的 turn
|
|
133
|
+
const r = await handleSessionRead({ action: 'detail', session: FIX_E6, turns: 'T000' }, { agentDir: dir })
|
|
75
134
|
const d = r.details as { entries: Array<{ type: string; message?: { role?: string } }> }
|
|
76
135
|
expect(d.entries.length).toBeGreaterThan(0)
|
|
77
136
|
// v2 O3:默认 toolResult 变摘要态(type=toolResultSummary),条目不消失
|
|
@@ -81,54 +140,47 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
81
140
|
})
|
|
82
141
|
|
|
83
142
|
it('4. family lists fork children and隔代 subagents', async () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// 而非失败——与 TC14-TC18 的「数据不存在则 return」守卫模式一致,避免偶发红。
|
|
87
|
-
// 注意必须用 hasAnyRealSession(双目录),hasRealSession 只扫 sessions/ 扫不到 019fe635。
|
|
88
|
-
if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
|
|
89
|
-
const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: REAL })
|
|
143
|
+
await writeFamilyFixtures()
|
|
144
|
+
const r = await handleSessionRead({ action: 'family', session: FAM }, { agentDir: dir })
|
|
90
145
|
const d = r.details as {
|
|
91
146
|
forks: Array<{ sessionId: string }>
|
|
92
147
|
subagents: Array<{ sessionId: string }>
|
|
93
148
|
}
|
|
94
|
-
expect(d.forks.some((f) => f.sessionId
|
|
95
|
-
// 隔代:
|
|
96
|
-
expect(d.subagents.some((s) => s.sessionId
|
|
149
|
+
expect(d.forks.some((f) => f.sessionId === FORK)).toBe(true)
|
|
150
|
+
// 隔代:SUB2.rootSessionId=FORK(fork 子代),从家族根 FAM 出发仍能关联
|
|
151
|
+
expect(d.subagents.some((s) => s.sessionId === SUB2)).toBe(true)
|
|
97
152
|
})
|
|
98
153
|
|
|
99
154
|
it('5. search pattern returns hits', async () => {
|
|
100
|
-
const r = await handleSessionRead({ action: 'search', session:
|
|
155
|
+
const r = await handleSessionRead({ action: 'search', session: FIX_E6, pattern: 'plugin' }, { agentDir: dir })
|
|
101
156
|
const d = r.details as { hits: Array<{ turnIndex: number; matchSnippet: string }> }
|
|
102
157
|
expect(d.hits.length).toBeGreaterThan(0)
|
|
103
158
|
expect(typeof d.hits[0].matchSnippet).toBe('string')
|
|
104
159
|
})
|
|
105
160
|
|
|
106
161
|
it('6. export outline materializes a .md file', async () => {
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
const
|
|
162
|
+
// export 写目标 = <agentDir>/tmp/(mkdtemp 自建自删);读侧经 liveSessionDir=[live]
|
|
163
|
+
// 根扫本 describe 的 fixture sessions 目录。
|
|
164
|
+
const exportDir = await mkdtemp(join(tmpdir(), 'tool-handler-export-'))
|
|
110
165
|
try {
|
|
111
166
|
const r = await handleSessionRead(
|
|
112
|
-
{ action: 'export', session:
|
|
113
|
-
{ agentDir:
|
|
167
|
+
{ action: 'export', session: FIX_E6, format: 'outline' },
|
|
168
|
+
{ agentDir: exportDir, liveSessionDir: join(dir, 'sessions') },
|
|
114
169
|
)
|
|
115
170
|
const d = r.details as { path: string; sizeBytes: number }
|
|
116
171
|
expect(d.path).toMatch(/\.md$/)
|
|
117
172
|
expect(existsSync(d.path)).toBe(true)
|
|
118
173
|
expect(d.sizeBytes).toBeGreaterThan(0)
|
|
119
174
|
} finally {
|
|
120
|
-
await rm(
|
|
175
|
+
await rm(exportDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
121
176
|
}
|
|
122
177
|
})
|
|
123
178
|
|
|
124
|
-
//
|
|
125
|
-
// wave 的 subagent task 引用了它)会触发 name-keyword fallback 命中 → 零匹配断言失败。
|
|
126
|
-
// w2 实现 handler source 透传后,用 source:'main' 收窄到 main 侧避开 subagent 干扰
|
|
127
|
-
//(w1 test 注释原预言的修复路径),同时恢复默认 timeout(main 单侧扫描快)。
|
|
179
|
+
// source:'main' 收窄到 main 侧(显式 source 查询走单组原语义,见 TC12/TC13)
|
|
128
180
|
it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
|
|
129
181
|
const r = await handleSessionRead(
|
|
130
182
|
{ action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
|
|
131
|
-
{ agentDir:
|
|
183
|
+
{ agentDir: dir },
|
|
132
184
|
)
|
|
133
185
|
const d = r.details as { matches: unknown[]; truncated: boolean }
|
|
134
186
|
expect(d.matches).toEqual([])
|
|
@@ -142,28 +194,131 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
142
194
|
|
|
143
195
|
it('8. F4 detail turn out of range throws with 越界', async () => {
|
|
144
196
|
await expect(
|
|
145
|
-
handleSessionRead({ action: 'detail', session:
|
|
197
|
+
handleSessionRead({ action: 'detail', session: FIX_E6, turns: 'T999' }, { agentDir: dir }),
|
|
146
198
|
).rejects.toThrow(/越界/)
|
|
147
199
|
})
|
|
148
200
|
|
|
149
201
|
it('9. F5 outline missing session throws naming session', async () => {
|
|
150
|
-
await expect(handleSessionRead({ action: 'outline' }, { agentDir:
|
|
202
|
+
await expect(handleSessionRead({ action: 'outline' }, { agentDir: dir })).rejects.toThrow(/session/)
|
|
151
203
|
})
|
|
152
204
|
|
|
153
205
|
it('10. resolveSessionId fragment equivalent to full id', async () => {
|
|
154
|
-
const rFull = await handleSessionRead({ action: 'outline', session:
|
|
155
|
-
const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir:
|
|
206
|
+
const rFull = await handleSessionRead({ action: 'outline', session: FIX_E6 }, { agentDir: dir })
|
|
207
|
+
const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: dir })
|
|
156
208
|
const dFull = rFull.details as { turns: unknown[] }
|
|
157
209
|
const dFrag = rFrag.details as { turns: unknown[] }
|
|
158
210
|
expect(dFrag.turns.length).toBe(dFull.turns.length)
|
|
159
211
|
})
|
|
160
|
-
}
|
|
212
|
+
})
|
|
161
213
|
|
|
162
|
-
describe
|
|
163
|
-
|
|
214
|
+
describe('extract (v2 O4)', () => {
|
|
215
|
+
let dir: string
|
|
216
|
+
const SLUG = '--demo-cwd--'
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* extract fixture session(FIX_E6):3 user turn 的最小多素材形态——
|
|
220
|
+
* - T000:bash git log(commits git-cmd 主路径:def5678a)+ bash ls + read
|
|
221
|
+
* (commits commit-context 次路径:abc1234f,hash 邻近含 'feat:')
|
|
222
|
+
* - T001:edit(files op 素材)
|
|
223
|
+
* - T002:纯文本收尾
|
|
224
|
+
* 计数锚:user 3 / toolCall 4(bash 2)/ toolResult 4(bash 2)。
|
|
225
|
+
*/
|
|
226
|
+
async function writeExtractFixture(): Promise<void> {
|
|
227
|
+
const sessionDir = join(dir, 'sessions', SLUG)
|
|
228
|
+
await mkdir(sessionDir, { recursive: true })
|
|
229
|
+
const msg = (id: string, parentId: string, message: Record<string, unknown>): string =>
|
|
230
|
+
JSON.stringify({ type: 'message', id, parentId, message })
|
|
231
|
+
const lines = [
|
|
232
|
+
JSON.stringify({ type: 'session', id: FIX_E6, cwd: '/demo' }),
|
|
233
|
+
msg(`${FIX_E6}-m0`, FIX_E6, {
|
|
234
|
+
role: 'user',
|
|
235
|
+
content: [{ type: 'text', text: '第一条:查一下最近的提交' }],
|
|
236
|
+
}),
|
|
237
|
+
msg(`${FIX_E6}-m1`, `${FIX_E6}-m0`, {
|
|
238
|
+
role: 'assistant',
|
|
239
|
+
content: [
|
|
240
|
+
{ type: 'text', text: '好,先看 git 记录' },
|
|
241
|
+
{ type: 'toolCall', id: 'tc-git', name: 'bash', arguments: { command: 'git log --oneline -3' } },
|
|
242
|
+
],
|
|
243
|
+
}),
|
|
244
|
+
msg(`${FIX_E6}-m2`, `${FIX_E6}-m1`, {
|
|
245
|
+
role: 'toolResult',
|
|
246
|
+
toolName: 'bash',
|
|
247
|
+
toolCallId: 'tc-git',
|
|
248
|
+
content: [{ type: 'text', text: 'def5678a feat: fix crash' }],
|
|
249
|
+
}),
|
|
250
|
+
msg(`${FIX_E6}-m3`, `${FIX_E6}-m2`, {
|
|
251
|
+
role: 'assistant',
|
|
252
|
+
content: [
|
|
253
|
+
{ type: 'text', text: '再看下文件列表' },
|
|
254
|
+
{ type: 'toolCall', id: 'tc-ls', name: 'bash', arguments: { command: 'ls src' } },
|
|
255
|
+
],
|
|
256
|
+
}),
|
|
257
|
+
msg(`${FIX_E6}-m4`, `${FIX_E6}-m3`, {
|
|
258
|
+
role: 'toolResult',
|
|
259
|
+
toolName: 'bash',
|
|
260
|
+
toolCallId: 'tc-ls',
|
|
261
|
+
content: [{ type: 'text', text: 'src/index.ts' }],
|
|
262
|
+
}),
|
|
263
|
+
msg(`${FIX_E6}-m5`, `${FIX_E6}-m4`, {
|
|
264
|
+
role: 'assistant',
|
|
265
|
+
content: [
|
|
266
|
+
{ type: 'text', text: '读一下说明' },
|
|
267
|
+
{ type: 'toolCall', id: 'tc-read', name: 'read', arguments: { path: '/repo/README.md' } },
|
|
268
|
+
],
|
|
269
|
+
}),
|
|
270
|
+
msg(`${FIX_E6}-m6`, `${FIX_E6}-m5`, {
|
|
271
|
+
role: 'toolResult',
|
|
272
|
+
toolName: 'read',
|
|
273
|
+
toolCallId: 'tc-read',
|
|
274
|
+
content: [{ type: 'text', text: 'deploy abc1234f done\nfeat: add login' }],
|
|
275
|
+
}),
|
|
276
|
+
msg(`${FIX_E6}-m7`, `${FIX_E6}-m6`, {
|
|
277
|
+
role: 'user',
|
|
278
|
+
content: [{ type: 'text', text: '第二条:改代码' }],
|
|
279
|
+
}),
|
|
280
|
+
msg(`${FIX_E6}-m8`, `${FIX_E6}-m7`, {
|
|
281
|
+
role: 'assistant',
|
|
282
|
+
content: [
|
|
283
|
+
{ type: 'text', text: '来改' },
|
|
284
|
+
{
|
|
285
|
+
type: 'toolCall',
|
|
286
|
+
id: 'tc-edit',
|
|
287
|
+
name: 'edit',
|
|
288
|
+
arguments: { path: '/repo/src/app.ts', edits: [{ oldText: 'a', newText: 'b' }] },
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
msg(`${FIX_E6}-m9`, `${FIX_E6}-m8`, {
|
|
293
|
+
role: 'toolResult',
|
|
294
|
+
toolName: 'edit',
|
|
295
|
+
toolCallId: 'tc-edit',
|
|
296
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
297
|
+
}),
|
|
298
|
+
msg(`${FIX_E6}-m10`, `${FIX_E6}-m9`, {
|
|
299
|
+
role: 'user',
|
|
300
|
+
content: [{ type: 'text', text: '第三条:完成' }],
|
|
301
|
+
}),
|
|
302
|
+
msg(`${FIX_E6}-m11`, `${FIX_E6}-m10`, {
|
|
303
|
+
role: 'assistant',
|
|
304
|
+
content: [{ type: 'text', text: '全部完成。' }],
|
|
305
|
+
}),
|
|
306
|
+
]
|
|
307
|
+
await writeFile(join(sessionDir, `${FIX_E6}.jsonl`), lines.join('\n') + '\n')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
beforeEach(async () => {
|
|
311
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-extract-'))
|
|
312
|
+
await writeExtractFixture()
|
|
313
|
+
})
|
|
314
|
+
afterEach(async () => {
|
|
315
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('user-messages returns all user entries with turn + full text', async () => {
|
|
164
319
|
const r = await handleSessionRead(
|
|
165
|
-
{ action: 'extract', session:
|
|
166
|
-
{ agentDir:
|
|
320
|
+
{ action: 'extract', session: FIX_E6, what: 'user-messages' },
|
|
321
|
+
{ agentDir: dir },
|
|
167
322
|
)
|
|
168
323
|
const d = r.details as {
|
|
169
324
|
what: string
|
|
@@ -173,7 +328,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
173
328
|
items: Array<{ turn: number; text: string }>
|
|
174
329
|
}
|
|
175
330
|
expect(d.what).toBe('user-messages')
|
|
176
|
-
expect(d.count).toBe(
|
|
331
|
+
expect(d.count).toBe(3) // 全量 user entry 计数锚(fixture 3 条 user)
|
|
177
332
|
// F9 预算可能截断:items 是 shown 子集,shown <= count
|
|
178
333
|
expect(d.shown).toBeLessThanOrEqual(d.count)
|
|
179
334
|
expect(d.items.length).toBe(d.shown)
|
|
@@ -183,17 +338,17 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
183
338
|
).toBe(true)
|
|
184
339
|
})
|
|
185
340
|
|
|
186
|
-
it('commands (no filter) returns
|
|
341
|
+
it('commands (no filter) returns all tool calls with name + summary', async () => {
|
|
187
342
|
const r = await handleSessionRead(
|
|
188
|
-
{ action: 'extract', session:
|
|
189
|
-
{ agentDir:
|
|
343
|
+
{ action: 'extract', session: FIX_E6, what: 'commands' },
|
|
344
|
+
{ agentDir: dir },
|
|
190
345
|
)
|
|
191
346
|
const d = r.details as {
|
|
192
347
|
count: number
|
|
193
348
|
shown: number
|
|
194
349
|
items: Array<{ name: string; summary: string }>
|
|
195
350
|
}
|
|
196
|
-
expect(d.count).toBe(
|
|
351
|
+
expect(d.count).toBe(4) // 全量 toolCall 计数锚(fixture:bash×2 + read + edit)
|
|
197
352
|
expect(d.shown).toBeLessThanOrEqual(d.count)
|
|
198
353
|
expect(d.items.length).toBe(d.shown)
|
|
199
354
|
expect(
|
|
@@ -206,44 +361,44 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
206
361
|
}
|
|
207
362
|
})
|
|
208
363
|
|
|
209
|
-
it('commands tool=bash returns
|
|
364
|
+
it('commands tool=bash returns 2 all bash', async () => {
|
|
210
365
|
const r = await handleSessionRead(
|
|
211
|
-
{ action: 'extract', session:
|
|
212
|
-
{ agentDir:
|
|
366
|
+
{ action: 'extract', session: FIX_E6, what: 'commands', tool: 'bash' },
|
|
367
|
+
{ agentDir: dir },
|
|
213
368
|
)
|
|
214
369
|
const d = r.details as {
|
|
215
370
|
count: number
|
|
216
371
|
shown: number
|
|
217
372
|
items: Array<{ name: string }>
|
|
218
373
|
}
|
|
219
|
-
expect(d.count).toBe(
|
|
374
|
+
expect(d.count).toBe(2) // bash toolCall 全量(F9 可能截断 shown 子集)
|
|
220
375
|
expect(d.items.every((it) => it.name === 'bash')).toBe(true)
|
|
221
376
|
})
|
|
222
377
|
|
|
223
378
|
it('commands tool=nonexist triggers F8 with tool distribution (no throw)', async () => {
|
|
224
379
|
const r = await handleSessionRead(
|
|
225
|
-
{ action: 'extract', session:
|
|
226
|
-
{ agentDir:
|
|
380
|
+
{ action: 'extract', session: FIX_E6, what: 'commands', tool: 'nonexist' },
|
|
381
|
+
{ agentDir: dir },
|
|
227
382
|
)
|
|
228
383
|
const d = r.details as {
|
|
229
384
|
toolDistribution: Array<{ name: string; count: number }>
|
|
230
385
|
}
|
|
231
386
|
expect(r.content[0].text).toContain('无匹配')
|
|
232
|
-
expect(r.content[0].text).toContain('bash×
|
|
387
|
+
expect(r.content[0].text).toContain('bash×2')
|
|
233
388
|
expect(r.content[0].text).toContain('👉')
|
|
234
|
-
expect(d.toolDistribution.some((t) => t.name === 'bash' && t.count ===
|
|
389
|
+
expect(d.toolDistribution.some((t) => t.name === 'bash' && t.count === 2)).toBe(true)
|
|
235
390
|
})
|
|
236
391
|
|
|
237
392
|
it('files returns deduped paths with op (read/edit/write/head)', async () => {
|
|
238
393
|
const r = await handleSessionRead(
|
|
239
|
-
{ action: 'extract', session:
|
|
240
|
-
{ agentDir:
|
|
394
|
+
{ action: 'extract', session: FIX_E6, what: 'files' },
|
|
395
|
+
{ agentDir: dir },
|
|
241
396
|
)
|
|
242
397
|
const d = r.details as {
|
|
243
398
|
count: number
|
|
244
399
|
items: Array<{ path: string; basename: string; op: string; turns: number[] }>
|
|
245
400
|
}
|
|
246
|
-
expect(d.count).toBeGreaterThan(0)
|
|
401
|
+
expect(d.count).toBeGreaterThan(0)
|
|
247
402
|
expect(d.items.length).toBeGreaterThan(0)
|
|
248
403
|
// 抽查含 .ts 或 .md 文件
|
|
249
404
|
expect(
|
|
@@ -257,14 +412,14 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
257
412
|
|
|
258
413
|
it('commits returns hash list with 7-8 hex + source turn', async () => {
|
|
259
414
|
const r = await handleSessionRead(
|
|
260
|
-
{ action: 'extract', session:
|
|
261
|
-
{ agentDir:
|
|
415
|
+
{ action: 'extract', session: FIX_E6, what: 'commits' },
|
|
416
|
+
{ agentDir: dir },
|
|
262
417
|
)
|
|
263
418
|
const d = r.details as {
|
|
264
419
|
count: number
|
|
265
420
|
items: Array<{ hash: string; turn: number; source: string; context: string }>
|
|
266
421
|
}
|
|
267
|
-
//
|
|
422
|
+
// git-cmd 主路径(bash git log 结果)取到 hash
|
|
268
423
|
expect(d.count).toBeGreaterThan(0)
|
|
269
424
|
// commits 已知会误匹配/漏报(D6),只验每条格式
|
|
270
425
|
for (const it of d.items) {
|
|
@@ -274,48 +429,48 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
274
429
|
}
|
|
275
430
|
})
|
|
276
431
|
|
|
277
|
-
it('tool-results (no filter) returns
|
|
432
|
+
it('tool-results (no filter) returns all results with toolName + text', async () => {
|
|
278
433
|
const r = await handleSessionRead(
|
|
279
|
-
{ action: 'extract', session:
|
|
280
|
-
{ agentDir:
|
|
434
|
+
{ action: 'extract', session: FIX_E6, what: 'tool-results' },
|
|
435
|
+
{ agentDir: dir },
|
|
281
436
|
)
|
|
282
437
|
const d = r.details as {
|
|
283
438
|
count: number
|
|
284
439
|
shown: number
|
|
285
440
|
items: Array<{ toolName: string; text: string }>
|
|
286
441
|
}
|
|
287
|
-
expect(d.count).toBe(
|
|
442
|
+
expect(d.count).toBe(4) // 全量 toolResult 计数锚(fixture 4 条)
|
|
288
443
|
expect(d.shown).toBeLessThanOrEqual(d.count)
|
|
289
444
|
expect(
|
|
290
445
|
d.items.every((it) => typeof it.toolName === 'string' && typeof it.text === 'string'),
|
|
291
446
|
).toBe(true)
|
|
292
447
|
})
|
|
293
448
|
|
|
294
|
-
it('tool-results tool=bash returns
|
|
449
|
+
it('tool-results tool=bash returns 2 all bash', async () => {
|
|
295
450
|
const r = await handleSessionRead(
|
|
296
|
-
{ action: 'extract', session:
|
|
297
|
-
{ agentDir:
|
|
451
|
+
{ action: 'extract', session: FIX_E6, what: 'tool-results', tool: 'bash' },
|
|
452
|
+
{ agentDir: dir },
|
|
298
453
|
)
|
|
299
454
|
const d = r.details as { count: number; items: Array<{ toolName: string }> }
|
|
300
|
-
expect(d.count).toBe(
|
|
455
|
+
expect(d.count).toBe(2)
|
|
301
456
|
expect(d.items.every((it) => it.toolName === 'bash')).toBe(true)
|
|
302
457
|
})
|
|
303
458
|
|
|
304
459
|
it('tool-results tool=nonexist triggers F8 (no throw)', async () => {
|
|
305
460
|
const r = await handleSessionRead(
|
|
306
|
-
{ action: 'extract', session:
|
|
307
|
-
{ agentDir:
|
|
461
|
+
{ action: 'extract', session: FIX_E6, what: 'tool-results', tool: 'nonexist' },
|
|
462
|
+
{ agentDir: dir },
|
|
308
463
|
)
|
|
309
464
|
expect(r.content[0].text).toContain('无匹配')
|
|
310
|
-
expect(r.content[0].text).toContain('bash×
|
|
465
|
+
expect(r.content[0].text).toContain('bash×2')
|
|
311
466
|
})
|
|
312
467
|
|
|
313
468
|
it('F7 missing what throws with 无效 + valid values', async () => {
|
|
314
469
|
// what 缺失是合法 SessionReadParams(optional),handler 层 F7 防御校验。
|
|
315
470
|
// isExtractWhat 对 undefined 返 false → 同一 throw 路径,覆盖非法值场景。
|
|
316
|
-
const params: SessionReadParams = { action: 'extract', session:
|
|
317
|
-
await expect(handleSessionRead(params, { agentDir:
|
|
318
|
-
await expect(handleSessionRead(params, { agentDir:
|
|
471
|
+
const params: SessionReadParams = { action: 'extract', session: FIX_E6 }
|
|
472
|
+
await expect(handleSessionRead(params, { agentDir: dir })).rejects.toThrow(/无效/)
|
|
473
|
+
await expect(handleSessionRead(params, { agentDir: dir })).rejects.toThrow(
|
|
319
474
|
/user-messages\/commands\/files\/commits\/tool-results/,
|
|
320
475
|
)
|
|
321
476
|
})
|
|
@@ -325,8 +480,8 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
325
480
|
let msg = ''
|
|
326
481
|
try {
|
|
327
482
|
await handleSessionRead(
|
|
328
|
-
{ action: 'extract', session:
|
|
329
|
-
{ agentDir:
|
|
483
|
+
{ action: 'extract', session: FIX_E6, what: 'user-messages', turns: 'T999' },
|
|
484
|
+
{ agentDir: dir },
|
|
330
485
|
)
|
|
331
486
|
} catch (e) {
|
|
332
487
|
msg = (e as Error).message
|
|
@@ -335,7 +490,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
|
|
|
335
490
|
expect(msg).not.toContain('用 outline 重看有效范围')
|
|
336
491
|
expect(msg).toContain('该 session extract 共')
|
|
337
492
|
})
|
|
338
|
-
}
|
|
493
|
+
})
|
|
339
494
|
|
|
340
495
|
// ---- fixture 工具(tmpdir 造最小 session 文件,供 F2/MF-5 用例)----
|
|
341
496
|
|
|
@@ -824,8 +979,9 @@ describe('renderExtractItems F9 截断(S3 首项超大 + S4 文案)', () =>
|
|
|
824
979
|
// ============================================================
|
|
825
980
|
|
|
826
981
|
/**
|
|
827
|
-
* 造 subagent manifest + session 文件(TC7/TC8/TC10/CQ3 用)。
|
|
982
|
+
* 造 subagent manifest + session 文件(TC7/TC8/TC10/CQ3/TC14-TC17 用)。
|
|
828
983
|
* sessionFileExists:false 模拟 GC(manifest 存在但 session 文件不存在 → ES1)。
|
|
984
|
+
* slug 可指定 cwd 编码目录名(TC15 worktree 形态),缺省 '--demo-cwd--'。
|
|
829
985
|
* 返回 session 文件绝对路径(= manifest.sessionFile)。
|
|
830
986
|
*/
|
|
831
987
|
async function makeFixtureSubagent(
|
|
@@ -837,9 +993,10 @@ async function makeFixtureSubagent(
|
|
|
837
993
|
agentName?: string
|
|
838
994
|
sessionFileExists?: boolean
|
|
839
995
|
firstUserText?: string
|
|
996
|
+
slug?: string
|
|
840
997
|
},
|
|
841
998
|
): Promise<string> {
|
|
842
|
-
const slug = '--demo-cwd--'
|
|
999
|
+
const slug = opts.slug ?? '--demo-cwd--'
|
|
843
1000
|
const sessionFile = join(dir, 'subagents', slug, 'sessions', `${opts.realSessionId}.jsonl`)
|
|
844
1001
|
if (opts.sessionFileExists !== false) {
|
|
845
1002
|
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
@@ -1026,28 +1183,32 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
1026
1183
|
}
|
|
1027
1184
|
})
|
|
1028
1185
|
|
|
1029
|
-
it('TC9: sa-id 0 命中(可能 running)→
|
|
1186
|
+
it('TC9: sa-id 0 命中(可能 running)→ zcode_record_not_found(U9:sa-id 语境统一错误面,含 family 指引 + 👉)', async () => {
|
|
1030
1187
|
await expect(
|
|
1031
1188
|
handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
|
|
1032
|
-
).rejects.toThrow('
|
|
1189
|
+
).rejects.toThrow('zcode_record_not_found')
|
|
1033
1190
|
try {
|
|
1034
1191
|
await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
|
|
1035
1192
|
} catch (e) {
|
|
1036
1193
|
const msg = (e as Error).message
|
|
1037
|
-
expect(msg).toContain('
|
|
1194
|
+
expect(msg).toContain('未落盘')
|
|
1038
1195
|
expect(msg).toContain('action:"family"')
|
|
1039
1196
|
expect(msg).toContain('👉')
|
|
1040
1197
|
}
|
|
1041
1198
|
})
|
|
1042
1199
|
|
|
1043
|
-
it('TC10: sa-id 片段输入(精确相等不命中)→
|
|
1200
|
+
it('TC10: sa-id 片段输入(精确相等不命中)→ zcode_record_not_found + 完整 sa- id 重试指引(U9 统一错误面)', async () => {
|
|
1044
1201
|
await makeFixtureSubagent(dir, 'sa-c8c8dfa8', {
|
|
1045
1202
|
realSessionId: '019e6c96-dddd-eeee-ffff-0000000010a3',
|
|
1046
1203
|
})
|
|
1047
|
-
// 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 →
|
|
1204
|
+
// 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中(兜底亦未命中)
|
|
1205
|
+
await expect(
|
|
1206
|
+
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
|
|
1207
|
+
).rejects.toThrow('zcode_record_not_found')
|
|
1208
|
+
// 片段输入恢复路径在指引内可见(A4:ES2 原文案「用完整 sa- id 重试」的覆盖补全)
|
|
1048
1209
|
await expect(
|
|
1049
1210
|
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
|
|
1050
|
-
).rejects.toThrow(
|
|
1211
|
+
).rejects.toThrow(/完整 sa- id(形如 `sa-xxxx`)后重试/)
|
|
1051
1212
|
})
|
|
1052
1213
|
|
|
1053
1214
|
it('sa-id 多 manifest 命中(数据异常)→ ES2 ambiguous(C4)', async () => {
|
|
@@ -1103,6 +1264,131 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
1103
1264
|
})
|
|
1104
1265
|
})
|
|
1105
1266
|
|
|
1267
|
+
describe('sa-id entry 兜底 v2 终态条锚定(W1 D1 版本门补齐,第三层兜底消费面)', () => {
|
|
1268
|
+
// fixture 骨架对齐 zcode-routing.test.ts:root/agent = agentDir、root/live-sessions =
|
|
1269
|
+
// liveSessionDir(entry 兜底候选根)、root/engines/zcode/session-db/db.sqlite = 隔离库
|
|
1270
|
+
// 白名单形态路径(不落文件——正向用例锚命中后进 zcode 读链,在白名单存在性检查处
|
|
1271
|
+
// 产出 zcode_db_unreadable,无需真实 sqlite)。
|
|
1272
|
+
const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
|
|
1273
|
+
let root: string
|
|
1274
|
+
let agentDir: string
|
|
1275
|
+
let liveDir: string
|
|
1276
|
+
let dbPath: string
|
|
1277
|
+
let appendEntries: ReturnType<typeof vi.fn>
|
|
1278
|
+
|
|
1279
|
+
beforeEach(async () => {
|
|
1280
|
+
root = await mkdtemp(join(tmpdir(), 'tool-handler-v2anchor-'))
|
|
1281
|
+
agentDir = join(root, 'agent')
|
|
1282
|
+
liveDir = join(root, 'live-sessions')
|
|
1283
|
+
await mkdir(agentDir, { recursive: true })
|
|
1284
|
+
await mkdir(liveDir, { recursive: true })
|
|
1285
|
+
dbPath = join(root, ...ISOLATED_DB_SEGMENTS)
|
|
1286
|
+
appendEntries = vi.fn()
|
|
1287
|
+
setPiHandle({ appendEntry: appendEntries } as unknown as Parameters<typeof setPiHandle>[0])
|
|
1288
|
+
})
|
|
1289
|
+
afterEach(async () => {
|
|
1290
|
+
setPiHandle(undefined)
|
|
1291
|
+
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
/** live session 内追加 subagent-record custom 行(首行 session header)。 */
|
|
1295
|
+
async function writeLiveEntryLine(lineData: Record<string, unknown>): Promise<void> {
|
|
1296
|
+
const path = join(liveDir, 'main.jsonl')
|
|
1297
|
+
await writeFile(
|
|
1298
|
+
path,
|
|
1299
|
+
JSON.stringify({ type: 'session', id: 'live-main-1', cwd: '/proj' }) +
|
|
1300
|
+
'\n' +
|
|
1301
|
+
JSON.stringify({ type: 'custom', customType: 'subagent-record', id: 'e-v2', parentId: null, data: lineData }) +
|
|
1302
|
+
'\n',
|
|
1303
|
+
)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** v2 终态条 data(载荷形态对齐 subagent-core SubagentRecordSettledEntryData)。 */
|
|
1307
|
+
function v2SettledData(opts: { engine?: string; sessionRef?: Record<string, string> } = {}): Record<string, unknown> {
|
|
1308
|
+
const data: Record<string, unknown> = {
|
|
1309
|
+
v: 2,
|
|
1310
|
+
kind: 'settled',
|
|
1311
|
+
id: 'sa-v2-1',
|
|
1312
|
+
status: 'idle',
|
|
1313
|
+
stopReason: 'end_turn',
|
|
1314
|
+
endedAt: 123,
|
|
1315
|
+
turns: 2,
|
|
1316
|
+
totalTokens: 20,
|
|
1317
|
+
model: undefined,
|
|
1318
|
+
thinkingLevel: undefined,
|
|
1319
|
+
result: 'done text',
|
|
1320
|
+
}
|
|
1321
|
+
if (opts.engine !== undefined) data.engine = opts.engine
|
|
1322
|
+
if (opts.sessionRef !== undefined) {
|
|
1323
|
+
data.engineHandle = { sessionRef: opts.sessionRef, poolKey: 'shared' }
|
|
1324
|
+
}
|
|
1325
|
+
return data
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** v2 注册条 data(身份域,不携带 engineHandle——非锚源)。 */
|
|
1329
|
+
function v2RegisteredData(): Record<string, unknown> {
|
|
1330
|
+
return {
|
|
1331
|
+
v: 2,
|
|
1332
|
+
kind: 'registered',
|
|
1333
|
+
id: 'sa-v2-1',
|
|
1334
|
+
agent: 'dev',
|
|
1335
|
+
task: 't',
|
|
1336
|
+
slug: 's',
|
|
1337
|
+
origin: 'tool',
|
|
1338
|
+
rootSessionId: 'live-main-1',
|
|
1339
|
+
depth: 0,
|
|
1340
|
+
startedAt: 1,
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function logCallsWithReason(reason: string): number {
|
|
1345
|
+
return appendEntries.mock.calls.filter(
|
|
1346
|
+
([ct, d]) => ct === 'session-reader:log' && (d as { data?: { reason?: string } })?.data?.reason === reason,
|
|
1347
|
+
).length
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
it('v2 终态条完整锚 → 兜底命中进 zcode 读链(dbPath 白名单内未落盘 → zcode_db_unreadable,非 not_found)', async () => {
|
|
1351
|
+
await writeLiveEntryLine(v2SettledData({ engine: 'zcode', sessionRef: { sessionId: 'sess_v2', dbPath } }))
|
|
1352
|
+
// 锚命中 → resolveZcodeRoute → zcode 读链 → 白名单存在性检查(文件未落盘)
|
|
1353
|
+
// zcode_db_unreadable = 锚定成功的输出级证据(锚未命中此处是 zcode_record_not_found)
|
|
1354
|
+
await expect(
|
|
1355
|
+
handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
|
|
1356
|
+
).rejects.toThrow('zcode_db_unreadable')
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
it('v2 终态条 engine=zcode 但 sessionRef 缺 dbPath → zcode_anchor_missing + 结构化日志记「缺 dbPath」', async () => {
|
|
1360
|
+
await writeLiveEntryLine(v2SettledData({ engine: 'zcode', sessionRef: { sessionId: 'sess_v2' } }))
|
|
1361
|
+
await expect(
|
|
1362
|
+
handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
|
|
1363
|
+
).rejects.toThrow('zcode_anchor_missing')
|
|
1364
|
+
// 归因进结构化日志不进 LLM 可见面(§3.4):firstIncompleteAnchorReason 的 v2 分支
|
|
1365
|
+
//(tool-handler 唯一消费点)产出 missing-dbPath
|
|
1366
|
+
expect(logCallsWithReason('missing-dbPath')).toBeGreaterThanOrEqual(1)
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
it('v2 注册条(kind="registered")不是锚源 → zcode_record_not_found(不误报 anchor_missing)', async () => {
|
|
1370
|
+
await writeLiveEntryLine(v2RegisteredData())
|
|
1371
|
+
await expect(
|
|
1372
|
+
handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
|
|
1373
|
+
).rejects.toThrow('zcode_record_not_found')
|
|
1374
|
+
await expect(
|
|
1375
|
+
handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
|
|
1376
|
+
).rejects.not.toThrow('zcode_anchor_missing')
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
it('v2 终态条 engine 缺省(pi 形态 v2)→ zcode_record_not_found(D5 engine 判别防误归因)', async () => {
|
|
1380
|
+
// pi record 的 v2 终态条:engine 缺省、sessionRef 无 dbPath——不是「zcode 锚不完整」,
|
|
1381
|
+
// 不适用旧版本产物指引;误归因 missing-dbPath → zcode_anchor_missing 即红
|
|
1382
|
+
await writeLiveEntryLine(v2SettledData())
|
|
1383
|
+
await expect(
|
|
1384
|
+
handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
|
|
1385
|
+
).rejects.toThrow('zcode_record_not_found')
|
|
1386
|
+
await expect(
|
|
1387
|
+
handleSessionRead({ action: 'outline', session: 'sa-v2-1' }, { agentDir, liveSessionDir: liveDir }),
|
|
1388
|
+
).rejects.not.toThrow('zcode_anchor_missing')
|
|
1389
|
+
})
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1106
1392
|
describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)', () => {
|
|
1107
1393
|
let dir: string
|
|
1108
1394
|
|
|
@@ -1179,54 +1465,64 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
1179
1465
|
})
|
|
1180
1466
|
})
|
|
1181
1467
|
|
|
1182
|
-
describe
|
|
1468
|
+
describe('subagent sa-id(w2 TC14-TC17,fixture)', () => {
|
|
1469
|
+
let dir: string
|
|
1470
|
+
const TC14_ID = '0aaaaaaa-0000-7000-8000-0000000014a1'
|
|
1471
|
+
const TC15_ID = '0aaaaaaa-0000-7000-8000-0000000015a2'
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* 两个存活 subagent + 一个 GC manifest:
|
|
1475
|
+
* - TC14 走普通 slug(sa-id → manifest → sessionFile → outline)
|
|
1476
|
+
* - TC15 的 sessionFile 落在 '--private-var-folders-' 编码目录(worktree cwd 形态),
|
|
1477
|
+
* 覆盖 manifest 树的递归扫描
|
|
1478
|
+
* - TC17 的 manifest sessionFile 指向不存在路径(模拟 .jsonl 被 GC)
|
|
1479
|
+
*/
|
|
1480
|
+
beforeEach(async () => {
|
|
1481
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-tc14-'))
|
|
1482
|
+
await makeFixtureSubagent(dir, 'sa-tc14', {
|
|
1483
|
+
realSessionId: TC14_ID,
|
|
1484
|
+
firstUserText: 'subagent task',
|
|
1485
|
+
})
|
|
1486
|
+
await makeFixtureSubagent(dir, 'sa-tc15', {
|
|
1487
|
+
realSessionId: TC15_ID,
|
|
1488
|
+
slug: '--private-var-folders-abc--',
|
|
1489
|
+
firstUserText: 'worktree subagent task',
|
|
1490
|
+
})
|
|
1491
|
+
await makeFixtureSubagent(dir, 'sa-tc17-gc', {
|
|
1492
|
+
realSessionId: '0aaaaaaa-0000-7000-8000-0000000017a3',
|
|
1493
|
+
sessionFileExists: false,
|
|
1494
|
+
})
|
|
1495
|
+
})
|
|
1496
|
+
afterEach(async () => {
|
|
1497
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1498
|
+
})
|
|
1499
|
+
|
|
1183
1500
|
it('TC14: completed subagent sa-id outline 成功(场景 1,sessionId=header 真实 id)', async () => {
|
|
1184
|
-
const
|
|
1185
|
-
const alive = manifests.filter((m) => existsSync(m.sessionFile))
|
|
1186
|
-
if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
|
|
1187
|
-
const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, { agentDir: REAL })
|
|
1501
|
+
const r = await handleSessionRead({ action: 'outline', session: 'sa-tc14' }, { agentDir: dir })
|
|
1188
1502
|
const d = r.details as { turns: unknown[] }
|
|
1189
1503
|
expect(d.turns.length).toBeGreaterThan(0)
|
|
1190
1504
|
})
|
|
1191
1505
|
|
|
1192
1506
|
it('TC15: worktree 编码目录 subagent 读取(场景 1b,递归扫描覆盖)', async () => {
|
|
1193
|
-
const
|
|
1194
|
-
const wt = manifests.filter(
|
|
1195
|
-
(m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
|
|
1196
|
-
)
|
|
1197
|
-
if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
|
|
1198
|
-
const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, { agentDir: REAL })
|
|
1507
|
+
const r = await handleSessionRead({ action: 'outline', session: 'sa-tc15' }, { agentDir: dir })
|
|
1199
1508
|
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
1200
1509
|
})
|
|
1201
1510
|
|
|
1202
1511
|
it('TC16: 嵌套后代直接 outline(场景 1c,绝对路径形态读任意节点)', async () => {
|
|
1203
|
-
const manifests = await listRecordManifests(REAL)
|
|
1204
|
-
const alive = manifests.filter((m) => existsSync(m.sessionFile))
|
|
1205
|
-
if (alive.length === 0) return
|
|
1206
1512
|
// 绝对路径形态直接读(M0 入口不依赖 findSessions)
|
|
1207
1513
|
const r = await handleSessionRead(
|
|
1208
|
-
{ action: 'outline', session:
|
|
1209
|
-
{ agentDir:
|
|
1514
|
+
{ action: 'outline', session: join(dir, 'subagents', '--demo-cwd--', 'sessions', `${TC14_ID}.jsonl`) },
|
|
1515
|
+
{ agentDir: dir },
|
|
1210
1516
|
)
|
|
1211
1517
|
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
1212
1518
|
})
|
|
1213
1519
|
|
|
1214
1520
|
it('TC17: GC/failed manifest → ES1(场景 4,sessionFile 不存在)', async () => {
|
|
1215
|
-
const manifests = await listRecordManifests(REAL)
|
|
1216
|
-
const gc = manifests.filter((m) => !existsSync(m.sessionFile))
|
|
1217
|
-
if (gc.length === 0) return // 本机无 GC 数据则跳过
|
|
1218
1521
|
await expect(
|
|
1219
|
-
handleSessionRead({ action: 'outline', session: gc
|
|
1522
|
+
handleSessionRead({ action: 'outline', session: 'sa-tc17-gc' }, { agentDir: dir }),
|
|
1220
1523
|
).rejects.toThrow('session 文件不存在')
|
|
1221
1524
|
})
|
|
1222
|
-
|
|
1223
|
-
it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
|
|
1224
|
-
const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
|
|
1225
|
-
await expect(
|
|
1226
|
-
handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
|
|
1227
|
-
).rejects.toThrow('无匹配 record')
|
|
1228
|
-
})
|
|
1229
|
-
}, 60000)
|
|
1525
|
+
})
|
|
1230
1526
|
|
|
1231
1527
|
// ============================================================
|
|
1232
1528
|
// w6: doWorkflow action(TC-w6-single-run/multi-run/runid-filter/runid-not-found/no-runs/snapshot-skip/call-jump)
|
|
@@ -1344,6 +1640,88 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1344
1640
|
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1345
1641
|
})
|
|
1346
1642
|
|
|
1643
|
+
// ---- [D16③] v2 record 流档概览(journalPath 锚点 = record 流路径)----
|
|
1644
|
+
|
|
1645
|
+
/** 向 main session 追加 workflow-record v2 注册条目行(journalPath 锚点指向 record 流)。 */
|
|
1646
|
+
async function wfV2Registered(
|
|
1647
|
+
dir: string,
|
|
1648
|
+
slug: string,
|
|
1649
|
+
id: string,
|
|
1650
|
+
runId: string,
|
|
1651
|
+
recordPath: string,
|
|
1652
|
+
): Promise<void> {
|
|
1653
|
+
const sessionPath = join(dir, 'sessions', slug, `${id}.jsonl`)
|
|
1654
|
+
const line = JSON.stringify({
|
|
1655
|
+
type: 'custom',
|
|
1656
|
+
id: `wf-reg-${runId}`,
|
|
1657
|
+
parentId: id,
|
|
1658
|
+
customType: 'workflow-record',
|
|
1659
|
+
data: { v: 2, kind: 'registered', runId, workflowName: 'rec-flow', scriptName: 'rec-flow', slug: 'rec-flow', startedAt: 1758000000000, journalPath: recordPath },
|
|
1660
|
+
timestamp: '2026-09-28T00:00:00Z',
|
|
1661
|
+
})
|
|
1662
|
+
await writeFile(sessionPath, line + '\n', { flag: 'a' })
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
it('[D16③] v2 档 record 流概览:注册条目锚点直读流——steps 提 sessionFile、status 三态、不进 skippedRuns(活跃 run 红线)', async () => {
|
|
1666
|
+
const slug = '--wf-record--'
|
|
1667
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1668
|
+
const callSession = join(dir, 'sessions', slug, `${WF_CALL}.jsonl`)
|
|
1669
|
+
const recordPath = await wfStateFile(dir, slug, 'wf-rec-1.record.jsonl', [
|
|
1670
|
+
JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-rec-1', workflowName: 'rec-flow', argsSummary: '{}' }),
|
|
1671
|
+
JSON.stringify({ type: 'agent-started', seq: 2, ts: 1758000000100, taskIndex: 0, agentName: 'rec-step', attempt: 1 }),
|
|
1672
|
+
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 } }),
|
|
1673
|
+
JSON.stringify({ type: 'run-settled', seq: 4, ts: 1758000002000, outcome: 'done', artifactsDir: '/tmp/wf' }),
|
|
1674
|
+
])
|
|
1675
|
+
await wfV2Registered(dir, slug, WF_ROOT, 'wf-rec-1', recordPath)
|
|
1676
|
+
|
|
1677
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1678
|
+
const d = r.details as {
|
|
1679
|
+
runs: Array<{ runId: string; status: string; script?: string; steps: Array<{ sessionId?: string; sessionFile?: string }> }>
|
|
1680
|
+
skippedRuns?: unknown[]
|
|
1681
|
+
}
|
|
1682
|
+
// 红线:record 流 run 正常解析——不退化为 skippedRuns
|
|
1683
|
+
expect(d.skippedRuns).toBeUndefined()
|
|
1684
|
+
expect(d.runs).toHaveLength(1)
|
|
1685
|
+
expect(d.runs[0].runId).toBe('wf-rec-1')
|
|
1686
|
+
expect(d.runs[0].status).toBe('done')
|
|
1687
|
+
expect(d.runs[0].script).toBe('rec-flow')
|
|
1688
|
+
expect(d.runs[0].steps).toHaveLength(1)
|
|
1689
|
+
expect(d.runs[0].steps[0].sessionFile).toBe(callSession)
|
|
1690
|
+
expect(d.runs[0].steps[0].sessionId).toBe(WF_CALL)
|
|
1691
|
+
expect(r.content[0].type).toBe('text')
|
|
1692
|
+
expect((r.content[0] as { text: string }).text).toContain('wf-rec-1')
|
|
1693
|
+
expect((r.content[0] as { text: string }).text).toContain('rec-step')
|
|
1694
|
+
})
|
|
1695
|
+
|
|
1696
|
+
it('[D16③][D2] v2 档活跃 run(流无终局帧)→ status running、概览不退化为 skippedRuns;中断转移帧 → interrupted', async () => {
|
|
1697
|
+
const slug = '--wf-running--'
|
|
1698
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1699
|
+
// 活跃:run-created + agent-started(无 settled/终局帧)
|
|
1700
|
+
const activePath = await wfStateFile(dir, slug, 'wf-run-1.record.jsonl', [
|
|
1701
|
+
JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-run-1', workflowName: 'run-flow', argsSummary: '{}' }),
|
|
1702
|
+
JSON.stringify({ type: 'agent-started', seq: 2, ts: 1758000000100, taskIndex: 0, agentName: 'in-flight', attempt: 1 }),
|
|
1703
|
+
])
|
|
1704
|
+
await wfV2Registered(dir, slug, WF_ROOT, 'wf-run-1', activePath)
|
|
1705
|
+
const r1 = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1706
|
+
const d1 = r1.details as { runs: Array<{ runId: string; status: string }>; skippedRuns?: unknown[] }
|
|
1707
|
+
expect(d1.skippedRuns).toBeUndefined()
|
|
1708
|
+
expect(d1.runs.find((x) => x.runId === 'wf-run-1')?.status).toBe('running')
|
|
1709
|
+
|
|
1710
|
+
// 中断:追加 run-interrupted 转移帧 → status interrupted([D2] 暂停态)
|
|
1711
|
+
// 同 slug 同 main session 追加条目(第二个 slug 目录会触发同 id 多匹配消歧)
|
|
1712
|
+
const slug2 = slug
|
|
1713
|
+
const intPath = await wfStateFile(dir, slug2, 'wf-int-1.record.jsonl', [
|
|
1714
|
+
JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId: 'wf-int-1', workflowName: 'int-flow', argsSummary: '{}' }),
|
|
1715
|
+
JSON.stringify({ type: 'agent-started', seq: 2, ts: 1758000000100, taskIndex: 0, agentName: 'crashed-step', attempt: 1 }),
|
|
1716
|
+
JSON.stringify({ type: 'run-interrupted', seq: 3, ts: 1758000009000, errorCode: 'crashed', reason: 'process killed' }),
|
|
1717
|
+
])
|
|
1718
|
+
await wfV2Registered(dir, slug2, WF_ROOT, 'wf-int-1', intPath)
|
|
1719
|
+
const r2 = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, { agentDir: dir })
|
|
1720
|
+
const d2 = r2.details as { runs: Array<{ runId: string; status: string }>; skippedRuns?: unknown[] }
|
|
1721
|
+
expect(d2.skippedRuns).toBeUndefined()
|
|
1722
|
+
expect(d2.runs.find((x) => x.runId === 'wf-int-1')?.status).toBe('interrupted')
|
|
1723
|
+
})
|
|
1724
|
+
|
|
1347
1725
|
it('TC-w6-single-run:单 run 概览,content 含 run 头行/budget/step,details.runs/runIds 非空', async () => {
|
|
1348
1726
|
const slug = '--wf-single--'
|
|
1349
1727
|
await wfMainSession(dir, slug, WF_ROOT)
|
|
@@ -1576,31 +1954,6 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1576
1954
|
})
|
|
1577
1955
|
})
|
|
1578
1956
|
|
|
1579
|
-
// ============================================================
|
|
1580
|
-
// 真实数据守卫:doWorkflow(CI 无本机数据时 skipIf 跳过)
|
|
1581
|
-
// ============================================================
|
|
1582
|
-
|
|
1583
|
-
const REAL_WF_SESSION = '019fdcda-75c7-74b7-a160-f67f6bf88384'
|
|
1584
|
-
const HAS_REAL_WF_SESSION = HAS_REAL && hasRealSession(REAL_WF_SESSION)
|
|
1585
|
-
|
|
1586
|
-
describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
|
|
1587
|
-
it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
|
|
1588
|
-
const r = await handleSessionRead(
|
|
1589
|
-
{ action: 'workflow', session: REAL_WF_SESSION },
|
|
1590
|
-
{ agentDir: REAL },
|
|
1591
|
-
)
|
|
1592
|
-
const d = r.details as {
|
|
1593
|
-
runs: Array<{ runId: string; status: string; steps: unknown[] }>
|
|
1594
|
-
runIds: string[]
|
|
1595
|
-
}
|
|
1596
|
-
expect(d.runs.length).toBeGreaterThan(0)
|
|
1597
|
-
expect(d.runIds.length).toBe(d.runs.length)
|
|
1598
|
-
// content 含 run 头行 + budget 行
|
|
1599
|
-
expect(r.content[0].text).toContain('run:')
|
|
1600
|
-
expect(r.content[0].text).toContain('budget:')
|
|
1601
|
-
}, 30000)
|
|
1602
|
-
}, 60000)
|
|
1603
|
-
|
|
1604
1957
|
// ============================================================
|
|
1605
1958
|
// m3b:doFamily recursive false/true(TC-m3b-dofamily-recursive-false/true)
|
|
1606
1959
|
// ============================================================
|