@zhushanwen/pi-session-reader 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "pi": {
@@ -13,6 +13,7 @@
13
13
  "pi-package"
14
14
  ],
15
15
  "devDependencies": {
16
+ "@vitest/coverage-v8": "^4.1.9",
16
17
  "vitest": "^4.1.8"
17
18
  },
18
19
  "files": [
@@ -703,7 +703,7 @@ describe.skipIf(!HAS_REAL)('buildExecutionTree - 真实数据守卫', () => {
703
703
  // 不抛错(已隐含:到这行说明成功)
704
704
  expect(tree.root.type).toBe('main')
705
705
  expect(tree.root.sessionId).toBe(FAM)
706
- })
706
+ }, 60000)
707
707
  })
708
708
 
709
709
  // ============================================================
@@ -6,7 +6,8 @@ import { Check } from 'typebox/value'
6
6
  * M3 pi 边界层(src/index.ts)单测(MF-6:此前该层零测试)。
7
7
  *
8
8
  * mock pi/ctx(as unknown as ExtensionAPI),测:
9
- * 1. execute catch isError:true 文本返回(「execute 不向 pi 抛」契约守护)
9
+ * 1. execute 错误路径 throw(W4:pi 只对 execute throw isError:true——agent-loop
10
+ * 丢弃返回值里的 isError 字段,「错误轮被标成功」契约守护)
10
11
  * 2. session_start handler 的 ctx.mode!=='tui' 守卫
11
12
  * 3. registeredPis WeakSet 去重:同 pi 二次 session_start 不重复注册;不同 pi 可注册
12
13
  * 4. typeof ctx.ui.addAutocompleteProvider 运行时守卫(ui 缺方法 → 跳过,不崩)
@@ -78,7 +79,7 @@ describe('sessionReaderExtension - execute 契约', () => {
78
79
  sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
79
80
  })
80
81
 
81
- it('handler 抛错 → execute 返回 isError:true + 👉 文本,不向 pi 抛', async () => {
82
+ it('handler 抛错 → execute pi throw(W4:throw 才置 isError:true,返回值 isError agent-loop 丢弃)', async () => {
82
83
  const toolDef = fake.registerTool.mock.calls[0][0] as {
83
84
  name: string
84
85
  execute: (
@@ -87,18 +88,14 @@ describe('sessionReaderExtension - execute 契约', () => {
87
88
  signal: AbortSignal | undefined,
88
89
  onUpdate: unknown,
89
90
  ctx: unknown,
90
- ) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>
91
+ ) => Promise<{ content: Array<{ type: string; text: string }> }>
91
92
  }
92
93
  expect(toolDef.name).toBe('session_read')
93
- // action=find 缺 query → F5 requireStr 抛错 → execute catch 转换
94
- const result = await toolDef.execute('tc-1', { action: 'find' }, undefined, undefined, undefined)
95
- expect(result.isError).toBe(true)
96
- expect(result.content[0].type).toBe('text')
97
- expect(result.content[0].text).toContain('👉')
98
- // 非 Error 抛错(string)也能转文本
99
- const result2 = await toolDef.execute('tc-2', { action: 123 }, undefined, undefined, undefined)
100
- expect(result2.isError).toBe(true)
101
- expect(typeof result2.content[0].text).toBe('string')
94
+ // action=find 缺 query → F5 requireStr 抛错 → execute 原样传播(pi catch
95
+ // isError:true + message 成为 toolResult content[0].text)
96
+ await expect(
97
+ toolDef.execute('tc-1', { action: 'find' }, undefined, undefined, undefined),
98
+ ).rejects.toThrow(/👉/)
102
99
  })
103
100
  })
104
101
 
@@ -117,18 +117,22 @@ describe('parseSessionContent', () => {
117
117
  expect(result.lastLinePartial).toBe(true)
118
118
  })
119
119
 
120
- it('custom entry 无顶层 id fallback 到 data.idpi subagent-identity 等格式)', () => {
121
- const content = line({
122
- type: 'custom',
123
- customType: 'subagent-identity',
124
- data: { id: 'sa-abc123', rootSessionId: 'sess-1', slug: 'fix' },
125
- })
120
+ it('custom entry 无顶层 id 坏行跳过(W4:data.id fallback 死分支已删,pi appendCustomEntry 恒写顶层 id)', () => {
121
+ // pi appendCustomEntry 恒写顶层 id(session-manager.js:820-828);data.id 是扩展
122
+ // 业务字段而非 entry id。无顶层 id 的行按坏行跳过(skippedLines++)。
123
+ const content = [
124
+ line({ type: 'custom', customType: 'ok-entry', id: 'top-level-id', data: {} }),
125
+ line({
126
+ type: 'custom',
127
+ customType: 'subagent-identity',
128
+ data: { id: 'sa-abc123', rootSessionId: 'sess-1', slug: 'fix' },
129
+ }),
130
+ ].join('\n')
126
131
  const result = parseSessionContent(content)
127
132
 
128
133
  expect(result.entries).toHaveLength(1)
129
- expect(result.entries[0].id).toBe('sa-abc123')
130
- expect(result.entries[0].customType).toBe('subagent-identity')
131
- expect(result.skippedLines).toBe(0)
134
+ expect(result.entries[0].id).toBe('top-level-id')
135
+ expect(result.skippedLines).toBe(1)
132
136
  })
133
137
 
134
138
  it('session header 无 parentId → 归一化为 null(root 判定)', () => {
@@ -162,5 +166,6 @@ describe('parseSessionFile', () => {
162
166
  expect(result.lastLinePartial).toBe(false)
163
167
  // 5.4MB 量级
164
168
  expect(result.totalBytes).toBeGreaterThan(5_000_000)
165
- })
169
+ // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
170
+ }, 60000)
166
171
  })
@@ -44,6 +44,26 @@ export function hasRealSession(sid: string): boolean {
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * 同步探测真实数据中任意 session 文件存在(main sessions/ 与 subagents/ 双目录)。
49
+ * 与 hasRealSession 的区别:subagent session 文件位于 subagents/<cwd>/sessions/ 下,
50
+ * hasRealSession 只扫主 sessions/ 目录扫不到。用于对活跃数据目录中具体文件(如 fork
51
+ * 子代/隔代 subagent)存在性的守卫探测。
52
+ */
53
+ export function hasAnyRealSession(fragment: string): boolean {
54
+ if (!existsSync(REAL_AGENT_DIR)) return false
55
+ try {
56
+ return (
57
+ execSync(
58
+ `find ${REAL_AGENT_DIR}/sessions ${REAL_AGENT_DIR}/subagents -name '*${fragment}*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
59
+ { encoding: 'utf8' },
60
+ ).trim().length > 0
61
+ )
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
47
67
  export const HAS_REAL_AGENT_DIR = existsSync(REAL_AGENT_DIR)
48
68
  export const HAS_REAL_SUBAGENTS_DIR = existsSync(join(REAL_AGENT_DIR, 'subagents'))
49
69
  export const HAS_REAL_SESSION = existsSync(REAL_SESSION)
@@ -185,7 +185,8 @@ describe('renderOutline', () => {
185
185
  // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
186
186
  // 准确值由 M2 工具层用 ParseResult.totalEntries 覆盖。M1 验量级。
187
187
  expect(result.stats.totalEntries).toBeGreaterThan(1000)
188
- })
188
+ // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
189
+ }, 60000)
189
190
  })
190
191
 
191
192
  describe('renderExpand', () => {
@@ -32,6 +32,21 @@ function hasRealSession(sid: string): boolean {
32
32
  return false
33
33
  }
34
34
  }
35
+
36
+ // 双目录版探测:subagent session 文件在 subagents/ 下,hasRealSession 扫不到。
37
+ // 用于对活跃数据目录中具体文件(fork 子代/隔代 subagent)存在性的守卫。
38
+ function hasAnyRealSession(fragment: string): boolean {
39
+ try {
40
+ return (
41
+ execSync(
42
+ `find ${REAL_AGENT_DIR}/sessions ${REAL_AGENT_DIR}/subagents -name '*${fragment}*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
43
+ { encoding: 'utf8' },
44
+ ).trim().length > 0
45
+ )
46
+ } catch {
47
+ return false
48
+ }
49
+ }
35
50
  const HAS_REAL_ROOT = hasRealSession('019fe620-8ae1-78a7-b76a-43a1ba4cc3c7')
36
51
  const HAS_REAL_WF = hasRealSession('019fdcda-75c7-74b7-a160-f67f6bf88384')
37
52
 
@@ -440,6 +455,9 @@ describe('buildFamilyFromFs - fixture', () => {
440
455
 
441
456
  describe.skipIf(!HAS_REAL_ROOT)('buildFamilyFromFs - 真实数据 ~/.pi/agent', () => {
442
457
  it('019fe620 family:fork 019fe632(跨 cwd)+ 隔代 subagent 019fe635(真实 id)', async () => {
458
+ // 数据守卫:019fe632(sessions/)与 019fe635(subagents/)位于活跃数据目录,
459
+ // 被 GC/重命名时跳过而非失败(同 TC14-TC18 守卫模式),避免偶发红。
460
+ if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
443
461
  const family = await buildFamilyFromFs(
444
462
  '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7',
445
463
  REAL_AGENT_DIR,
@@ -458,6 +476,17 @@ describe.skipIf(!HAS_REAL_ROOT)('buildFamilyFromFs - 真实数据 ~/.pi/agent',
458
476
 
459
477
  describe.skipIf(!HAS_REAL_WF)('buildFamilyFromFs - 真实 workflow 数据', () => {
460
478
  it('019fdcda:workflows 非空,至少一个 workflow calls>=4', async () => {
479
+ // 数据守卫:workflow-state 快照文件可能被清理(wf-state 是会话运行产物),
480
+ // 019fdcda 的 workflow-state-link 指向的快照不存在时跳过而非失败。
481
+ const wfFile = execSync(
482
+ `find ${REAL_AGENT_DIR}/sessions -name '*019fdcda*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
483
+ { encoding: 'utf8' },
484
+ ).trim()
485
+ if (!wfFile) return
486
+ const raw = execSync(`grep -c 'workflow-state-link' '${wfFile}' 2>/dev/null || true`, {
487
+ encoding: 'utf8',
488
+ }).trim()
489
+ if (raw === '0' || raw === '') return
461
490
  const family = await buildFamilyFromFs(
462
491
  '019fdcda-75c7-74b7-a160-f67f6bf88384',
463
492
  REAL_AGENT_DIR,
@@ -682,6 +711,9 @@ const HAS_REAL_SUBAGENTS_DIR = existsSync(join(REAL_AGENT_DIR, 'subagents'))
682
711
 
683
712
  describe.skipIf(!HAS_REAL_ROOT || !HAS_REAL_SUBAGENTS_DIR)('U4 真实数据守卫:~/.pi/agent', () => {
684
713
  it('TC-u4-real-data-guard: 019fe620 subagents 富字段透传(manifest 主 task 非空率 > 80%)', async () => {
714
+ // 数据守卫:019fe635 是 019fe620 家族已知的隔代 subagent(含 task 富字段的锚点),
715
+ // 被 GC 后跳过富字段验证(manifest 数据不可复现,跳过比失败合理)。
716
+ if (!hasAnyRealSession('019fe635')) return
685
717
  const family = await buildFamilyFromFs(
686
718
  '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7',
687
719
  REAL_AGENT_DIR,
@@ -12,6 +12,7 @@ import {
12
12
  HAS_E6,
13
13
  HAS_REAL,
14
14
  HAS_REAL_SUBAGENTS_DIR,
15
+ hasAnyRealSession,
15
16
  hasRealSession,
16
17
  } from './real-data.js'
17
18
 
@@ -27,6 +28,8 @@ import {
27
28
  * renderExtractItems F9 截断是纯 fixture,无条件跑。
28
29
  */
29
30
 
31
+ // 真实数据套件 timeout 说明:find 全扫描 + 5.6MB 文件解析在并发/高负载下可能超过
32
+ // vitest 默认 5s(pnpm extensions:test 全量跑时多包集成测试并发 IO),显式放宽到 60s。
30
33
  describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
31
34
  it('1. find by uuid fragment returns matching session', async () => {
32
35
  const r = await handleSessionRead({ action: 'find', query: 'e6c96' }, REAL)
@@ -54,6 +57,11 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
54
57
  })
55
58
 
56
59
  it('4. family lists fork children and隔代 subagents', async () => {
60
+ // 数据守卫:fork 子代 019fe632(sessions/)与隔代 subagent 019fe635(subagents/)
61
+ // 位于活跃数据目录(subagents 目录随 GC/新建持续变化),文件被清理/重命名时跳过
62
+ // 而非失败——与 TC14-TC18 的「数据不存在则 return」守卫模式一致,避免偶发红。
63
+ // 注意必须用 hasAnyRealSession(双目录),hasRealSession 只扫 sessions/ 扫不到 019fe635。
64
+ if (!hasAnyRealSession('019fe632') || !hasAnyRealSession('019fe635')) return
57
65
  const r = await handleSessionRead({ action: 'family', session: FAM }, REAL)
58
66
  const d = r.details as {
59
67
  forks: Array<{ sessionId: string }>
@@ -114,7 +122,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
114
122
  const dFrag = rFrag.details as { turns: unknown[] }
115
123
  expect(dFrag.turns.length).toBe(dFull.turns.length)
116
124
  })
117
- })
125
+ }, 60000)
118
126
 
119
127
  describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
120
128
  it('user-messages returns 26 user entries with turn + full text', async () => {
@@ -292,7 +300,7 @@ describe.skipIf(!HAS_E6)('extract (v2 O4)', () => {
292
300
  expect(msg).not.toContain('用 outline 重看有效范围')
293
301
  expect(msg).toContain('该 session extract 共')
294
302
  })
295
- })
303
+ }, 60000)
296
304
 
297
305
  // ---- fixture 工具(tmpdir 造最小 session 文件,供 F2/MF-5 用例)----
298
306
 
@@ -364,6 +372,48 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
364
372
  })
365
373
  })
366
374
 
375
+ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)', () => {
376
+ let dir: string
377
+ const SID = '019e6c96-bbbb-cccc-dddd-00000000000b'
378
+
379
+ beforeEach(async () => {
380
+ dir = await mkdtemp(join(tmpdir(), 'tool-handler-skipped-'))
381
+ })
382
+ afterEach(async () => {
383
+ await rm(dir, { recursive: true, force: true })
384
+ })
385
+
386
+ it('坏行计入 stats.skippedLines 且文本尾部可见(不静默跳过)', async () => {
387
+ // 中间注入 1 行坏 JSON(半截对象):parser 计 skippedLines=1,outline 必须报告
388
+ const slug = '--demo-cwd--'
389
+ await mkdir(join(dir, 'sessions', slug), { recursive: true })
390
+ const lines = [
391
+ JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
392
+ JSON.stringify({
393
+ type: 'message',
394
+ id: SID + '-m1',
395
+ parentId: SID,
396
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
397
+ }),
398
+ '{"broken json line',
399
+ ]
400
+ await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
401
+
402
+ const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
403
+ const d = r.details as { stats: { skippedLines: number } }
404
+ expect(d.stats.skippedLines).toBe(1)
405
+ expect(r.content[0].text).toContain('1 skipped lines')
406
+ })
407
+
408
+ it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
409
+ await makeFixtureSession(dir, SID, 'clean session')
410
+ const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
411
+ const d = r.details as { stats: { skippedLines: number } }
412
+ expect(d.stats.skippedLines).toBe(0)
413
+ expect(r.content[0].text).not.toContain('skipped lines')
414
+ })
415
+ })
416
+
367
417
  describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
368
418
  let dir: string
369
419
  const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
@@ -924,7 +974,7 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
924
974
  handleSessionRead({ action: 'outline', session: fakeId }, REAL),
925
975
  ).rejects.toThrow('无匹配 record')
926
976
  })
927
- })
977
+ }, 60000)
928
978
 
929
979
  // ============================================================
930
980
  // w6: doWorkflow action(TC-w6-single-run/multi-run/runid-filter/runid-not-found/no-runs/snapshot-skip/call-jump)
@@ -1297,7 +1347,7 @@ describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
1297
1347
  expect(r.content[0].text).toContain('run:')
1298
1348
  expect(r.content[0].text).toContain('budget:')
1299
1349
  }, 30000)
1300
- })
1350
+ }, 60000)
1301
1351
 
1302
1352
  // ============================================================
1303
1353
  // m3b:doFamily recursive false/true(TC-m3b-dofamily-recursive-false/true)
@@ -138,5 +138,6 @@ describe('segmentTurns', () => {
138
138
  expect(turns.filter((t) => t.isCompaction).length).toBe(5)
139
139
  // 其中有 26 个 user turn
140
140
  expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(26)
141
- })
141
+ // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
142
+ }, 60000)
142
143
  })
@@ -5,7 +5,7 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
6
 
7
7
  import { parseRunSnapshot, renderWorkflowOverview } from '../core/workflow.js'
8
- import { readRunSnapshot } from '../discovery/workflows.js'
8
+ import { extractCallSessionFiles, readRunSnapshot } from '../discovery/workflows.js'
9
9
  import { REAL_AGENT_DIR } from './real-data.js'
10
10
 
11
11
  // ============================================================
@@ -48,6 +48,42 @@ const NEW_SNAPSHOT_FIXTURE = {
48
48
  meta: { startedAt: '2026-08-03T13:05:50.111Z', completedAt: '2026-08-03T13:05:55.384Z' },
49
49
  }
50
50
 
51
+ /**
52
+ * v2 格式 fixture(pi-subagent-workflow 8.x 一次性生命周期写入的 wf-run-v2 快照)。
53
+ * 读取面形状与 v1 一致(state.calls[].sessionFile/result 保留),差异仅版本字面量与
54
+ * status 两态(running/done)、meta 无 pausedAt——v2 不该因版本字面量被挡在读之外。
55
+ */
56
+ const V2_SNAPSHOT_FIXTURE = {
57
+ v: 'wf-run-v2',
58
+ runId: 'wf-ignore',
59
+ spec: { scriptName: 'one-shot-flow', name: 'Flow Name', scriptSource: '// ...' },
60
+ state: {
61
+ status: 'done',
62
+ reason: 'completed',
63
+ budget: { usedTokens: 22000, usedCost: 0, totalCallCount: 2, maxTokens: 200000 },
64
+ calls: [
65
+ {
66
+ id: 0,
67
+ opts: { prompt: 'task 0', model: 'default', thinkingLevel: 'high', description: 'v2-step-0' },
68
+ status: 'done',
69
+ attempts: 1,
70
+ sessionId: '019v2a',
71
+ sessionFile: '/abs/v2-call0.jsonl',
72
+ traceNode: { stepIndex: 0, agent: 'dev-W1', task: 'task 0', model: 'default', status: 'completed', phase: 'P0' },
73
+ },
74
+ {
75
+ id: 1,
76
+ opts: { prompt: 'task 1', model: 'default' },
77
+ status: 'done',
78
+ attempts: 1,
79
+ result: { content: 'OK', durationMs: 567, sessionId: '019v2b', sessionFile: '/abs/v2-call1.jsonl' },
80
+ traceNode: { stepIndex: 1, agent: 'dev-W2', task: 'task 1', model: 'default', status: 'completed', phase: 'P1' },
81
+ },
82
+ ],
83
+ },
84
+ meta: { startedAt: '2026-08-10T10:00:00.000Z', completedAt: '2026-08-10T10:05:00.000Z' },
85
+ }
86
+
51
87
  /** OLD 格式 fixture(对齐 wf-skip-ok.jsonl:无 v,callCache value 无 sessionFile/result)。 */
52
88
  const OLD_SNAPSHOT_FIXTURE = {
53
89
  runId: 'wf-old-ignore',
@@ -98,6 +134,23 @@ describe('parseRunSnapshot', () => {
98
134
  expect(step.contentPreview).toBe('PROBE-OK')
99
135
  })
100
136
 
137
+ it('TC-w5-parse-new-v2:v2 快照(pi-subagent-workflow 8.x 写入)解析非 null,calls sessionFile 读出', () => {
138
+ const overview = parseRunSnapshot(V2_SNAPSHOT_FIXTURE, 'wf-v2-link-runid', '/abs/wf-v2.jsonl')
139
+ expect(overview).not.toBeNull()
140
+ expect(overview!.version).toBe('wf-run-v2')
141
+ expect(overview!.status).toBe('done')
142
+ expect(overview!.reason).toBe('completed')
143
+ expect(overview!.script).toBe('one-shot-flow')
144
+ // steps:两个 call 的 sessionFile 均读出(顶层优先 / result.sessionFile 回退)
145
+ expect(overview!.steps).toHaveLength(2)
146
+ expect(overview!.steps[0].sessionFile).toBe('/abs/v2-call0.jsonl')
147
+ expect(overview!.steps[0].sessionId).toBe('019v2a')
148
+ expect(overview!.steps[0].description).toBe('v2-step-0')
149
+ expect(overview!.steps[1].sessionFile).toBe('/abs/v2-call1.jsonl')
150
+ expect(overview!.steps[1].sessionId).toBe('019v2b')
151
+ expect(overview!.steps[1].contentPreview).toBe('OK')
152
+ })
153
+
101
154
  it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
102
155
  const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
103
156
  expect(overview).not.toBeNull()
@@ -123,13 +176,31 @@ describe('parseRunSnapshot', () => {
123
176
  })
124
177
 
125
178
  it('TC-w5-parse-malformed:既非 NEW 也非 OLD(缺关键字段)返回 null', () => {
126
- // (a) 有 v 但 v!=='wf-run-v1' 且无 callCache(未来版本 wf-run-v2)
127
- expect(parseRunSnapshot({ v: 'wf-run-v2', state: { calls: [] } }, 'r', 's')).toBeNull()
179
+ // (a) 有 v 但 vv1/v2 且无 callCache(未来版本 wf-run-v3 及之后——届时须评估
180
+ // 新版本读取面形状再扩判定,v2 因形状兼容被接受)
181
+ expect(parseRunSnapshot({ v: 'wf-run-v3', state: { calls: [] } }, 'r', 's')).toBeNull()
128
182
  // (b) 无 v 无 callCache 无 status(异构对象)
129
183
  expect(parseRunSnapshot({ foo: 'bar', baz: 1 }, 'r', 's')).toBeNull()
130
184
  })
131
185
  })
132
186
 
187
+ // ============================================================
188
+ // extractCallSessionFiles(纯逻辑,family/workflows 腿的 sessionFile 提取入口)
189
+ // ============================================================
190
+
191
+ describe('extractCallSessionFiles', () => {
192
+ it('v2 快照(wf-run-v2)state.calls 的 sessionFile 能被读出,不因版本字面量挡读', () => {
193
+ const files = extractCallSessionFiles(V2_SNAPSHOT_FIXTURE)
194
+ // 顶层 sessionFile + result.sessionFile 回退,两个 call 都读出
195
+ expect(files).toEqual(['/abs/v2-call0.jsonl', '/abs/v2-call1.jsonl'])
196
+ })
197
+
198
+ it('v1 快照(wf-run-v1)行为不变:state.calls 提取', () => {
199
+ const files = extractCallSessionFiles(NEW_SNAPSHOT_FIXTURE)
200
+ expect(files).toEqual(['/abs/session.jsonl'])
201
+ })
202
+ })
203
+
133
204
  // ============================================================
134
205
  // renderWorkflowOverview(纯逻辑,TC-w5-render-new/old)
135
206
  // ============================================================
@@ -60,15 +60,11 @@ function toEntry(raw: unknown): Entry | undefined {
60
60
  const obj = raw as Record<string, unknown>
61
61
  if (typeof obj.type !== 'string') return undefined
62
62
 
63
- // id 解析:顶层 id 优先;custom entry 无顶层 id 时 fallback 到 data.id
64
- //(pi subagent-identity custom entry id 放在 data.id,非顶层——真实样本确认)
65
- let id: unknown = obj.id
66
- if (typeof id !== 'string' && obj.type === 'custom') {
67
- const data = obj.data
68
- if (typeof data === 'object' && data !== null && typeof (data as Record<string, unknown>).id === 'string') {
69
- id = (data as Record<string, unknown>).id
70
- }
71
- }
63
+ // id 解析:pi appendCustomEntry 恒写顶层 id(session-manager.js:820-828)——
64
+ // 无需 fallback。data.id 是扩展业务字段(如 subagent-identity payload),不是 entry id
65
+ // [W4 删除] data.id fallback 分支对 pi 写出的 entry 是死分支(对历史/异构文件
66
+ // 也无必要——entry id 语义以顶层为准)。
67
+ const id: unknown = obj.id
72
68
  if (typeof id !== 'string') return undefined
73
69
 
74
70
  const entry: Entry = {
@@ -26,7 +26,8 @@ export interface TurnBrief {
26
26
 
27
27
  export interface OutlineResult {
28
28
  turns: TurnBrief[]
29
- stats: { totalTurns: number; totalEntries: number; totalBytes: number; parsedBytes: number }
29
+ /** skippedLines:JSON 解析失败/缺结构字段的行数(parser 检测,工具层覆盖精确值;render 层无 ParseResult 0) */
30
+ stats: { totalTurns: number; totalEntries: number; totalBytes: number; parsedBytes: number; skippedLines: number }
30
31
  /** chars / 4 近似(与 design P-outline 口径一致) */
31
32
  tokenEstimate: number
32
33
  /** 被总预算截断的 turn 数(从尾部丢弃) */
@@ -319,6 +320,7 @@ export function renderOutline(
319
320
  totalEntries: totalEntriesAll,
320
321
  totalBytes: parsedBytes,
321
322
  parsedBytes,
323
+ skippedLines: 0,
322
324
  }
323
325
 
324
326
  if (turns.length === 0) {
@@ -3,7 +3,7 @@
3
3
  // ============================================================
4
4
  //
5
5
  // 本文件消费 discovery/workflows.ts 的 readRunSnapshot(返 unknown 原始快照对象),
6
- // 把 unknown 类型化为 WorkflowOverview(NEW v='wf-run-v1' / OLD 无 v 双格式分支),
6
+ // 把 unknown 类型化为 WorkflowOverview(NEW v='wf-run-v1'/'wf-run-v2' / OLD 无 v 双格式分支),
7
7
  // 再渲染为人类可读文本。零 IO:parseRunSnapshot/renderWorkflowOverview 喂 mock 即可单测
8
8
  //(w5 TC-wf-core-pure-logic,对齐 session-reader core/* 纯逻辑约定)。
9
9
  //
@@ -78,8 +78,8 @@ export interface WorkflowOverview {
78
78
  stateFile: string
79
79
  /** NEW state.status / OLD 顶层 status */
80
80
  status: string
81
- /** 格式标记(渲染/调试用) */
82
- version: 'wf-run-v1' | 'legacy'
81
+ /** 格式标记(渲染/调试用)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
82
+ version: 'wf-run-v1' | 'wf-run-v2' | 'legacy'
83
83
  /** NEW spec.scriptName / spec.name / OLD name */
84
84
  script?: string
85
85
  /** NEW meta.startedAt / OLD startedAt(统一 string) */
@@ -199,9 +199,9 @@ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
199
199
  *
200
200
  * 分支(C-parserunsnapshot-dualformat,TC-wf-snapshot-version-union):
201
201
  * - 非对象 → null(调用方跳过,ES-wf-snapshot-unparseable)
202
- * - NEW (snapshot.v === 'wf-run-v1'):state.* / meta.* / spec.*
202
+ * - NEW (snapshot.v === 'wf-run-v1' 或 'wf-run-v2',读取面形状一致):state.* / meta.* / spec.*
203
203
  * - OLD (无 v,有 callCache 数组或顶层 status):顶层 status/budget/startedAt + callCache
204
- * - 既非 NEW 也非 OLD → null(未来版本 wf-run-v2 / 异构内容)
204
+ * - 既非 NEW 也非 OLD → null(未来版本(如 wf-run-v3)/ 异构内容)
205
205
  *
206
206
  * runId/stateFile 透传参数(不读 snapshot.runId,保证与 family.workflows 一致,避免 OLD 顶层
207
207
  * runId 可信度低的不一致)。零 any(全程 typeof/Array.isArray/isRecord 守卫收窄)。
@@ -213,8 +213,9 @@ export function parseRunSnapshot(
213
213
  ): WorkflowOverview | null {
214
214
  if (!isRecord(snapshot)) return null
215
215
 
216
- // NEW 格式(v === 'wf-run-v1')
217
- if (snapshot.v === 'wf-run-v1') {
216
+ // NEW 格式(v === 'wf-run-v1' || 'wf-run-v2',v2 读取面形状兼容 v1
217
+ const v = snapshot.v
218
+ if (v === 'wf-run-v1' || v === 'wf-run-v2') {
218
219
  const state = isRecord(snapshot.state) ? snapshot.state : {}
219
220
  const meta = isRecord(snapshot.meta) ? snapshot.meta : {}
220
221
  const spec = isRecord(snapshot.spec) ? snapshot.spec : {}
@@ -223,7 +224,7 @@ export function parseRunSnapshot(
223
224
  runId,
224
225
  stateFile,
225
226
  status: typeof state.status === 'string' ? state.status : '',
226
- version: 'wf-run-v1',
227
+ version: v,
227
228
  script:
228
229
  typeof spec.scriptName === 'string'
229
230
  ? spec.scriptName
@@ -19,8 +19,9 @@ export interface SessionFileMeta {
19
19
 
20
20
  /**
21
21
  * main sessions 扫描时整体跳过的子目录名。
22
- * `workflow-state` 目录存放 workflow 运行状态文件(wf-*.jsonl,首行 `{"v":"wf-run-v1"...}`),
23
- * session 文件——属 family 腿独立处理(design §3.3 D-7),扫描 main sessions 时排除,
22
+ * `workflow-state` 目录存放 workflow 运行状态文件(wf-*.jsonl,首行 `{"v":"wf-run-v1"|"wf-run-v2"...}`,
23
+ * 版本随 subagent-workflow 快照格式演进,读取侧 v1/v2 兼容),非 session 文件——属 family
24
+ * 独立处理(design §3.3 D-7),扫描 main sessions 时排除,
24
25
  * 否则会把 wf 文件误收为 session(且 find.ts 读其首行 header 时会因 type≠session 被丢弃,
25
26
  * 在此排除可避免这批无效首行扫描)。
26
27
  */
@@ -25,14 +25,16 @@ import { extractSessionIdFromFilename } from './subagents.js'
25
25
  * 从 wf-state 快照对象提取 calls[].sessionFile(绝对路径数组)。
26
26
  *
27
27
  * 两种格式(探查确认,本机 371 个 wf 文件):
28
- * - NEW (v="wf-run-v1"):state.calls[],每项顶层 .sessionFile(258 文件 / 1590 sessionFile)
28
+ * - NEW (v="wf-run-v1" 或 "wf-run-v2",读取面形状一致):state.calls[],每项顶层
29
+ * .sessionFile(258 文件 / 1590 sessionFile)。v2 由 pi-subagent-workflow 8.x 一次性
30
+ * 生命周期收敛引入(status 两态、无 pausedAt),calls[].sessionFile/result 保留
29
31
  * - OLD (无 v):callCache[]=[{key,value}],value.sessionFile(112 文件 / 0 sessionFile,旧 pi 不持久化)
30
32
  */
31
33
  export function extractCallSessionFiles(snap: unknown): string[] {
32
34
  const out: string[] = []
33
35
  if (typeof snap !== 'object' || snap === null) return out
34
36
  const s = snap as Record<string, unknown>
35
- const isNew = s.v === 'wf-run-v1'
37
+ const isNew = s.v === 'wf-run-v1' || s.v === 'wf-run-v2'
36
38
  let callsRaw: unknown
37
39
  if (isNew) {
38
40
  const state = s.state
package/src/index.ts CHANGED
@@ -12,7 +12,9 @@ import { createSessionCommand } from './tui/session-command.js'
12
12
  * 分层(同 scheduler/cw-tool):
13
13
  * - tool-handler.ts:纯逻辑 handler,agentDir 注入,零 pi 依赖,可单测
14
14
  * - index.ts(本文件):pi 依赖层,registerTool + getAgentDir() 调用 + execute 闭包
15
- * (catch handler 抛的 Error isError:true,execute 不向 pi 抛——pi 工具契约)
15
+ * (错误直接 throw pi——pi-agent-core agent-loop 只对 execute throw
16
+ * isError:true,返回值里的 isError 字段被丢弃;W4 修复,锚点
17
+ * agent-loop.js:453-483/525-547,pi 自带 bash 工具同范式)
16
18
  *
17
19
  * M4 将在此 addAutocompleteProvider(TUI # 补全,ctx.mode === 'tui' 时)。
18
20
  */
@@ -164,17 +166,12 @@ export default function sessionReaderExtension(pi: ExtensionAPI): void {
164
166
  _onUpdate: unknown,
165
167
  _ctx: ExtensionContext,
166
168
  ) {
167
- try {
168
- // signal search 消费(MF-5:长扫描可中断,Esc 不再挂死);其余 action 有界不接
169
- return await handleSessionRead(params, getAgentDir(), signal)
170
- } catch (e) {
171
- const msg = e instanceof Error ? e.message : String(e)
172
- return {
173
- content: [{ type: 'text' as const, text: msg }],
174
- details: {},
175
- isError: true,
176
- }
177
- }
169
+ // 错误路径直接 throw:pi 契约只有 throw 才置 isError:true(tool_execution_end /
170
+ // ToolResultMessage),handler 抛的 Error 文案(含 👉 恢复提示)原样成为
171
+ // toolResult content,模型仍可读到。曾用 return {isError:true}——被 agent-loop
172
+ // 丢弃,错误轮被标成功(W4 修复)。
173
+ // signal search 消费(MF-5:长扫描可中断,Esc 不再挂死);其余 action 有界不接
174
+ return handleSessionRead(params, getAgentDir(), signal)
178
175
  },
179
176
  })
180
177
 
@@ -7,8 +7,9 @@
7
7
  * 按 action 分发到 8 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
8
8
  *(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
9
9
  *
10
- * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),由 index.ts 的 execute
11
- * 闭包 catch isError:true 文本返回——handler 可抛(纯逻辑可测),execute 不抛(pi 契约)。
10
+ * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
11
+ * 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
12
+ * 字段被丢弃,agent-loop.js:453-483)。handler 可抛(纯逻辑可测)。
12
13
  * 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
13
14
  */
14
15
  import { existsSync, openSync, readSync, closeSync } from 'node:fs'
@@ -425,7 +426,9 @@ function formatOutlineText(r: OutlineResult): string {
425
426
  })
426
427
  const tail = [
427
428
  '',
428
- `${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens`,
429
+ `${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens${
430
+ r.stats.skippedLines > 0 ? ` · ${r.stats.skippedLines} skipped lines` : ''
431
+ }`,
429
432
  r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail 或调大 budget]` : '',
430
433
  ]
431
434
  .filter(Boolean)
@@ -677,7 +680,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
677
680
  async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
678
681
  const resolved = await resolveSessionId(params.session, 'outline', agentDir, params.source)
679
682
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
680
- const { entries, totalBytes } = await safeParse(resolved.fileName)
683
+ const { entries, totalBytes, skippedLines } = await safeParse(resolved.fileName)
681
684
  const tree = buildTreeView(entries)
682
685
  const turns = segmentTurns(entries, new Set(tree.leafPath))
683
686
  const opts: OutlineOptions = {
@@ -689,6 +692,9 @@ async function doOutline(params: SessionReadParams, agentDir: string): Promise<T
689
692
  // 覆盖 stats.totalBytes:render 用 parsedBytes(leaf entry JSON 字节和)近似,
690
693
  // 此处用 ParseResult.totalBytes(原始文件字节数,design §3.4 stats.totalBytes 语义)
691
694
  result.stats.totalBytes = totalBytes
695
+ // [D8d] skippedLines 同模式覆盖:parser 已检测坏行计数(render 签名不含 ParseResult 恒 0),
696
+ // 有检测必有报告——静默跳过行对调用方不可见 = 数据完整性缺口
697
+ result.stats.skippedLines = skippedLines
692
698
  return { content: [{ type: 'text', text: formatOutlineText(result) }], details: result }
693
699
  }
694
700