@zhushanwen/pi-session-reader 0.7.0 → 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.
@@ -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
- * 传真实 `~/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
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
- // 真实数据套件 timeout 说明:find 全扫描 + 5.6MB 文件解析在并发/高负载下可能超过
56
- // vitest 默认 5s(pnpm extensions:test 全量跑时多包集成测试并发 IO),显式放宽到 60s。
57
- describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
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: REAL })
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 32 turns within token budget (v2 O1: <=1500)', async () => {
66
- const r = await handleSessionRead({ action: 'outline', session: E6 }, { agentDir: REAL })
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(32)
69
- // v2 O1:加 assistantBrief + 修 toolSummary bug 后阈值 600→1500(design §3.3 D4)
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
- const r = await handleSessionRead({ action: 'detail', session: E6, turns: 'T001' }, { agentDir: REAL })
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
- // 数据守卫:fork 子代 019fe632(sessions/)与隔代 subagent 019fe635(subagents/)
85
- // 位于活跃数据目录(subagents 目录随 GC/新建持续变化),文件被清理/重命名时跳过
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.startsWith('019fe632'))).toBe(true)
95
- // 隔代:019fe635.rootSessionId=019fe632(fork 子代),从家族根 FAM 出发仍能关联
96
- expect(d.subagents.some((s) => s.sessionId.startsWith('019fe635'))).toBe(true)
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: E6, pattern: 'plugin' }, { agentDir: REAL })
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
- // fs-guard:export 写目标 = <agentDir>/tmp/,agentDir 传 REAL 会 mkdir 真实 ~/.pi/agent/tmp
108
- // (白名单外)。读侧经 liveSessionDir=[live] 根仍扫真实数据,写侧落 mkdtemp tmp 自建自删。
109
- const dir = await mkdtemp(join(tmpdir(), 'tool-handler-export-'))
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: E6, format: 'outline' },
113
- { agentDir: dir, liveSessionDir: join(REAL, 'sessions') },
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(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
175
+ await rm(exportDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
121
176
  }
122
177
  })
123
178
 
124
- // w1 合并 subagent 候选后,本机 subagent task 文本(含本用例 query 字符串本身,因当前
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: REAL },
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: E6, turns: 'T999' }, { agentDir: REAL }),
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: REAL })).rejects.toThrow(/session/)
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: E6 }, { agentDir: REAL })
155
- const rFrag = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir: REAL })
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
- }, 60000)
212
+ })
161
213
 
162
- describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
163
- it('user-messages returns 26 user entries with turn + full text', async () => {
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: E6, what: 'user-messages' },
166
- { agentDir: REAL },
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(26) // 全量 user entry 数(design §1:26 user)
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 519 tool calls with name + summary', async () => {
341
+ it('commands (no filter) returns all tool calls with name + summary', async () => {
187
342
  const r = await handleSessionRead(
188
- { action: 'extract', session: E6, what: 'commands' },
189
- { agentDir: REAL },
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(519) // 全量 toolCall(design §2.3:519)
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 309 all bash', async () => {
364
+ it('commands tool=bash returns 2 all bash', async () => {
210
365
  const r = await handleSessionRead(
211
- { action: 'extract', session: E6, what: 'commands', tool: 'bash' },
212
- { agentDir: REAL },
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(309) // bash toolCall 全量(F9 可能截断 shown 子集)
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: E6, what: 'commands', tool: 'nonexist' },
226
- { agentDir: REAL },
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×309')
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 === 309)).toBe(true)
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: E6, what: 'files' },
240
- { agentDir: REAL },
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) // 开发 session 有大量文件操作
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: E6, what: 'commits' },
261
- { agentDir: REAL },
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
- // 真实开发 session 有 git log/show 操作 → git-cmd 主路径应取到 hash
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 515 results with toolName + text', async () => {
432
+ it('tool-results (no filter) returns all results with toolName + text', async () => {
278
433
  const r = await handleSessionRead(
279
- { action: 'extract', session: E6, what: 'tool-results' },
280
- { agentDir: REAL },
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(515) // 全量 toolResult(design §1:515 toolResult)
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 309 all bash', async () => {
449
+ it('tool-results tool=bash returns 2 all bash', async () => {
295
450
  const r = await handleSessionRead(
296
- { action: 'extract', session: E6, what: 'tool-results', tool: 'bash' },
297
- { agentDir: REAL },
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(309)
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: E6, what: 'tool-results', tool: 'nonexist' },
307
- { agentDir: REAL },
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×309')
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: E6 }
317
- await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(/无效/)
318
- await expect(handleSessionRead(params, { agentDir: REAL })).rejects.toThrow(
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: E6, what: 'user-messages', turns: 'T999' },
329
- { agentDir: REAL },
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
- }, 60000)
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 })
@@ -1107,6 +1264,131 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
1107
1264
  })
1108
1265
  })
1109
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
+
1110
1392
  describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)', () => {
1111
1393
  let dir: string
1112
1394
 
@@ -1183,54 +1465,64 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
1183
1465
  })
1184
1466
  })
1185
1467
 
1186
- describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC14-TC18)', () => {
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
+
1187
1500
  it('TC14: completed subagent sa-id outline 成功(场景 1,sessionId=header 真实 id)', async () => {
1188
- const manifests = await listRecordManifests(REAL)
1189
- const alive = manifests.filter((m) => existsSync(m.sessionFile))
1190
- if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
1191
- const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, { agentDir: REAL })
1501
+ const r = await handleSessionRead({ action: 'outline', session: 'sa-tc14' }, { agentDir: dir })
1192
1502
  const d = r.details as { turns: unknown[] }
1193
1503
  expect(d.turns.length).toBeGreaterThan(0)
1194
1504
  })
1195
1505
 
1196
1506
  it('TC15: worktree 编码目录 subagent 读取(场景 1b,递归扫描覆盖)', async () => {
1197
- const manifests = await listRecordManifests(REAL)
1198
- const wt = manifests.filter(
1199
- (m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
1200
- )
1201
- if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
1202
- const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, { agentDir: REAL })
1507
+ const r = await handleSessionRead({ action: 'outline', session: 'sa-tc15' }, { agentDir: dir })
1203
1508
  expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1204
1509
  })
1205
1510
 
1206
1511
  it('TC16: 嵌套后代直接 outline(场景 1c,绝对路径形态读任意节点)', async () => {
1207
- const manifests = await listRecordManifests(REAL)
1208
- const alive = manifests.filter((m) => existsSync(m.sessionFile))
1209
- if (alive.length === 0) return
1210
1512
  // 绝对路径形态直接读(M0 入口不依赖 findSessions)
1211
1513
  const r = await handleSessionRead(
1212
- { action: 'outline', session: alive[0].sessionFile },
1213
- { agentDir: REAL },
1514
+ { action: 'outline', session: join(dir, 'subagents', '--demo-cwd--', 'sessions', `${TC14_ID}.jsonl`) },
1515
+ { agentDir: dir },
1214
1516
  )
1215
1517
  expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
1216
1518
  })
1217
1519
 
1218
1520
  it('TC17: GC/failed manifest → ES1(场景 4,sessionFile 不存在)', async () => {
1219
- const manifests = await listRecordManifests(REAL)
1220
- const gc = manifests.filter((m) => !existsSync(m.sessionFile))
1221
- if (gc.length === 0) return // 本机无 GC 数据则跳过
1222
1521
  await expect(
1223
- handleSessionRead({ action: 'outline', session: gc[0].id }, { agentDir: REAL }),
1522
+ handleSessionRead({ action: 'outline', session: 'sa-tc17-gc' }, { agentDir: dir }),
1224
1523
  ).rejects.toThrow('session 文件不存在')
1225
1524
  })
1226
-
1227
- it('TC18: 不存在 sa-id → zcode_record_not_found(场景 4,U9 统一错误面)', async () => {
1228
- const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
1229
- await expect(
1230
- handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
1231
- ).rejects.toThrow('zcode_record_not_found')
1232
- })
1233
- }, 60000)
1525
+ })
1234
1526
 
1235
1527
  // ============================================================
1236
1528
  // w6: doWorkflow action(TC-w6-single-run/multi-run/runid-filter/runid-not-found/no-runs/snapshot-skip/call-jump)
@@ -1348,6 +1640,88 @@ describe('doWorkflow(w6,fixture)', () => {
1348
1640
  await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1349
1641
  })
1350
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
+
1351
1725
  it('TC-w6-single-run:单 run 概览,content 含 run 头行/budget/step,details.runs/runIds 非空', async () => {
1352
1726
  const slug = '--wf-single--'
1353
1727
  await wfMainSession(dir, slug, WF_ROOT)
@@ -1580,31 +1954,6 @@ describe('doWorkflow(w6,fixture)', () => {
1580
1954
  })
1581
1955
  })
1582
1956
 
1583
- // ============================================================
1584
- // 真实数据守卫:doWorkflow(CI 无本机数据时 skipIf 跳过)
1585
- // ============================================================
1586
-
1587
- const REAL_WF_SESSION = '019fdcda-75c7-74b7-a160-f67f6bf88384'
1588
- const HAS_REAL_WF_SESSION = HAS_REAL && hasRealSession(REAL_WF_SESSION)
1589
-
1590
- describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
1591
- it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
1592
- const r = await handleSessionRead(
1593
- { action: 'workflow', session: REAL_WF_SESSION },
1594
- { agentDir: REAL },
1595
- )
1596
- const d = r.details as {
1597
- runs: Array<{ runId: string; status: string; steps: unknown[] }>
1598
- runIds: string[]
1599
- }
1600
- expect(d.runs.length).toBeGreaterThan(0)
1601
- expect(d.runIds.length).toBe(d.runs.length)
1602
- // content 含 run 头行 + budget 行
1603
- expect(r.content[0].text).toContain('run:')
1604
- expect(r.content[0].text).toContain('budget:')
1605
- }, 30000)
1606
- }, 60000)
1607
-
1608
1957
  // ============================================================
1609
1958
  // m3b:doFamily recursive false/true(TC-m3b-dofamily-recursive-false/true)
1610
1959
  // ============================================================