@zhushanwen/pi-session-reader 0.1.0 → 0.2.1

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.
@@ -98,4 +98,24 @@ describe('buildTreeView', () => {
98
98
  expect(t.branches.size).toBe(0)
99
99
  expect(t.orphans).toEqual([])
100
100
  })
101
+
102
+ it('subagent-identity 尾行(parentId=null)不劫持 leafId:跳过取末尾对话节点', () => {
103
+ // 模拟 subagent session:header A → message B → message C(对话链),尾行 identity sa-x(parentId=null)
104
+ const entries = [e('A', null), e('B', 'A'), e('C', 'B'), e('sa-x', null)]
105
+ const t = buildTreeView(entries)
106
+ // leafId 跳过 sa-x(parentId=null),取 C(末尾 parentId!==null)→ leafPath 含完整对话链
107
+ expect(t.leafPath).toEqual(['A', 'B', 'C'])
108
+ // sa-x parentId=null → 独立 root,不计旁支不计孤儿
109
+ expect(t.branches.size).toBe(0)
110
+ expect(t.orphans).toEqual([])
111
+ })
112
+
113
+ it('全空 session(仅 header + 元数据 custom,无 parentId!==null 对话节点)→ leafId fallback entries[0]', () => {
114
+ const entries = [e('A', null), e('sa-x', null)]
115
+ const t = buildTreeView(entries)
116
+ // 全 parentId=null → leafId fallback entries[0]=A → leafPath=[A]
117
+ expect(t.leafPath).toEqual(['A'])
118
+ expect(t.branches.size).toBe(0)
119
+ expect(t.orphans).toEqual([])
120
+ })
101
121
  })
@@ -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
  })
@@ -0,0 +1,353 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { existsSync } from 'node:fs'
4
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+
7
+ import { parseRunSnapshot, renderWorkflowOverview } from '../core/workflow.js'
8
+ import { extractCallSessionFiles, readRunSnapshot } from '../discovery/workflows.js'
9
+ import { REAL_AGENT_DIR } from './real-data.js'
10
+
11
+ // ============================================================
12
+ // fixture(结构对齐真实 wf-state 探针数据)
13
+ // ============================================================
14
+
15
+ /**
16
+ * NEW 格式 fixture(对齐 ~/.pi/agent/workflow-state/wf-1785762350110-d297tr.jsonl)。
17
+ * runId 故意写成 'wf-ignore' 验证 parseRunSnapshot 用参数透传不读 snapshot.runId。
18
+ */
19
+ const NEW_SNAPSHOT_FIXTURE = {
20
+ v: 'wf-run-v1',
21
+ runId: 'wf-ignore',
22
+ spec: { scriptName: 'thinkinglevel-probe', name: 'Probe Name', scriptSource: '// ...' },
23
+ state: {
24
+ status: 'done',
25
+ reason: 'completed',
26
+ budget: { usedTokens: 11060.24, usedCost: 0, totalCallCount: 1, maxTokens: 100000 },
27
+ calls: [
28
+ {
29
+ id: 0,
30
+ opts: {
31
+ prompt: 'Reply with exactly: PROBE-OK',
32
+ model: 'deepseek-router/ds-pro',
33
+ thinkingLevel: 'high',
34
+ description: 'step-0',
35
+ },
36
+ status: 'done',
37
+ attempts: 1,
38
+ result: {
39
+ content: 'PROBE-OK',
40
+ durationMs: 1234,
41
+ sessionId: '019xxx',
42
+ sessionFile: '/abs/session.jsonl',
43
+ usage: { input: 10546 },
44
+ },
45
+ },
46
+ ],
47
+ },
48
+ meta: { startedAt: '2026-08-03T13:05:50.111Z', completedAt: '2026-08-03T13:05:55.384Z' },
49
+ }
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
+
87
+ /** OLD 格式 fixture(对齐 wf-skip-ok.jsonl:无 v,callCache value 无 sessionFile/result)。 */
88
+ const OLD_SNAPSHOT_FIXTURE = {
89
+ runId: 'wf-old-ignore',
90
+ name: 'workflow-wf-skip-ok',
91
+ status: 'running',
92
+ callCache: [{ key: 7, value: { content: '', usage: { input: 0 } } }],
93
+ trace: [],
94
+ worker: 'agent-test',
95
+ startedAt: '2026-01-01T00:00:00Z',
96
+ budget: { usedTokens: 0, usedCost: 0 },
97
+ }
98
+
99
+ // ============================================================
100
+ // parseRunSnapshot(纯逻辑,TC-w5-parse-new/old/corrupt/malformed)
101
+ // ============================================================
102
+
103
+ describe('parseRunSnapshot', () => {
104
+ it('TC-w5-parse-new:NEW 格式 (v=wf-run-v1) 字段映射,runId/stateFile 参数透传', () => {
105
+ const overview = parseRunSnapshot(NEW_SNAPSHOT_FIXTURE, 'wf-link-runid', '/abs/wf.jsonl')
106
+ expect(overview).not.toBeNull()
107
+ // 参数透传(不读 snapshot.runId)
108
+ expect(overview!.runId).toBe('wf-link-runid')
109
+ expect(overview!.stateFile).toBe('/abs/wf.jsonl')
110
+ // 顶层字段
111
+ expect(overview!.version).toBe('wf-run-v1')
112
+ expect(overview!.status).toBe('done')
113
+ expect(overview!.reason).toBe('completed')
114
+ expect(overview!.script).toBe('thinkinglevel-probe') // spec.scriptName 优先于 name
115
+ expect(overview!.startedAt).toBe('2026-08-03T13:05:50.111Z')
116
+ expect(overview!.completedAt).toBe('2026-08-03T13:05:55.384Z')
117
+ // budget 透传
118
+ expect(overview!.budget.usedTokens).toBe(11060.24)
119
+ expect(overview!.budget.usedCost).toBe(0)
120
+ expect(overview!.budget.totalCallCount).toBe(1)
121
+ expect(overview!.budget.maxTokens).toBe(100000)
122
+ // steps
123
+ expect(overview!.steps).toHaveLength(1)
124
+ const step = overview!.steps[0]
125
+ expect(step.index).toBe(0)
126
+ expect(step.status).toBe('done')
127
+ expect(step.description).toBe('step-0')
128
+ expect(step.model).toBe('deepseek-router/ds-pro')
129
+ expect(step.thinkingLevel).toBe('high')
130
+ expect(step.attempts).toBe(1)
131
+ expect(step.durationMs).toBe(1234)
132
+ expect(step.sessionId).toBe('019xxx')
133
+ expect(step.sessionFile).toBe('/abs/session.jsonl')
134
+ expect(step.contentPreview).toBe('PROBE-OK')
135
+ })
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
+
154
+ it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
155
+ const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
156
+ expect(overview).not.toBeNull()
157
+ expect(overview!.version).toBe('legacy')
158
+ expect(overview!.status).toBe('running') // 顶层 status
159
+ expect(overview!.script).toBe('workflow-wf-skip-ok') // name 映射
160
+ expect(overview!.startedAt).toBe('2026-01-01T00:00:00Z')
161
+ expect(overview!.budget.usedTokens).toBe(0)
162
+ expect(overview!.budget.usedCost).toBe(0)
163
+ // steps
164
+ expect(overview!.steps).toHaveLength(1)
165
+ const step = overview!.steps[0]
166
+ expect(step.index).toBe(0) // callCache 顺序索引
167
+ expect(step.status).toBe('pending') // content='' 空串不算完成标志 → pending
168
+ expect(step.sessionFile).toBeUndefined() // OLD 未持久化
169
+ expect(step.contentPreview).toBe('') // value.content='' 仍提取
170
+ })
171
+
172
+ it('TC-w5-parse-corrupt-nonobject:非对象输入(null/undefined/string/number/array)返回 null', () => {
173
+ for (const bad of [null, undefined, 'string', 42, [1, 2, 3]] as unknown[]) {
174
+ expect(parseRunSnapshot(bad, 'r', 's')).toBeNull()
175
+ }
176
+ })
177
+
178
+ it('TC-w5-parse-malformed:既非 NEW 也非 OLD(缺关键字段)返回 null', () => {
179
+ // (a) 有 v 但 v 非 v1/v2 且无 callCache(未来版本 wf-run-v3 及之后——届时须评估
180
+ // 新版本读取面形状再扩判定,v2 因形状兼容被接受)
181
+ expect(parseRunSnapshot({ v: 'wf-run-v3', state: { calls: [] } }, 'r', 's')).toBeNull()
182
+ // (b) 无 v 无 callCache 无 status(异构对象)
183
+ expect(parseRunSnapshot({ foo: 'bar', baz: 1 }, 'r', 's')).toBeNull()
184
+ })
185
+ })
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
+
204
+ // ============================================================
205
+ // renderWorkflowOverview(纯逻辑,TC-w5-render-new/old)
206
+ // ============================================================
207
+
208
+ describe('renderWorkflowOverview', () => {
209
+ it('TC-w5-render-new:NEW 概览含 run 头/budget/steps,step 含 call sessionId 截断 + sessionFile 绝对路径', () => {
210
+ const overview = {
211
+ runId: 'wf-run-1',
212
+ stateFile: '/abs/state.jsonl',
213
+ status: 'done',
214
+ version: 'wf-run-v1' as const,
215
+ script: 'probe',
216
+ startedAt: '2026-01-01T00:00:00Z',
217
+ completedAt: '2026-01-01T00:01:00Z',
218
+ budget: { usedTokens: 11060, usedCost: 0, totalCallCount: 2, maxTokens: 100000 },
219
+ steps: [
220
+ {
221
+ index: 0,
222
+ status: 'done' as const,
223
+ model: 'm1',
224
+ durationMs: 100,
225
+ sessionId: '019aaa',
226
+ sessionFile: '/abs/a.jsonl',
227
+ },
228
+ {
229
+ index: 1,
230
+ status: 'done' as const,
231
+ model: 'm1',
232
+ durationMs: 200,
233
+ sessionId: '019bbb',
234
+ sessionFile: '/abs/b.jsonl',
235
+ },
236
+ ],
237
+ }
238
+ const out = renderWorkflowOverview(overview)
239
+ // 头行
240
+ expect(out).toContain('run: wf-run-1')
241
+ expect(out).toContain('[done]')
242
+ // budget 行
243
+ expect(out).toContain('budget:')
244
+ expect(out).toContain('used=11060tok')
245
+ expect(out).toContain('calls=2')
246
+ expect(out).toContain('max=100000tok')
247
+ // 每个 step 行
248
+ expect(out).toContain('#0')
249
+ expect(out).toContain('#1')
250
+ expect(out).toContain('model=m1')
251
+ expect(out).toContain('100ms')
252
+ expect(out).toContain('call=019aaa') // sessionId 截断(6 字符 <= 12)
253
+ expect(out).toContain('/abs/a.jsonl') // sessionFile 绝对路径(跳转入口)
254
+ expect(out).toContain('/abs/b.jsonl')
255
+ })
256
+
257
+ it('TC-w5-render-old:OLD 概览 step sessionFile 缺标「(无 sessionFile,OLD 格式未持久化)」', () => {
258
+ const overview = {
259
+ runId: 'wf-old-1',
260
+ stateFile: '/abs/old.jsonl',
261
+ status: 'running',
262
+ version: 'legacy' as const,
263
+ budget: { usedTokens: 0 },
264
+ steps: [{ index: 0, status: 'pending' as const, sessionFile: undefined }],
265
+ }
266
+ const out = renderWorkflowOverview(overview)
267
+ expect(out).toContain('run:')
268
+ expect(out).toContain('[running]')
269
+ expect(out).toContain('budget:')
270
+ expect(out).toContain('#0')
271
+ expect(out).toContain('[pending]')
272
+ expect(out).toContain('(无 sessionFile,OLD 格式未持久化)')
273
+ // 不输出 'sessionFile=undefined' 字面量
274
+ expect(out).not.toContain('sessionFile=undefined')
275
+ })
276
+ })
277
+
278
+ // ============================================================
279
+ // readRunSnapshot(IO,TC-w5-read-tail-fallback/no-file/all-unparseable)
280
+ // ============================================================
281
+
282
+ describe('readRunSnapshot', () => {
283
+ let dir: string
284
+ afterEach(async () => {
285
+ if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {})
286
+ })
287
+
288
+ it('TC-w5-read-tail-fallback:末行半截 JSON 回退倒数第二行完整快照', async () => {
289
+ dir = await mkdtemp(join(tmpdir(), 'wf-read-tail-'))
290
+ const path = join(dir, 'wf.jsonl')
291
+ // 第 1 行完整 NEW snapshot + 半截第 2 行(模拟 rewrite 中点)
292
+ const fullLine = JSON.stringify(NEW_SNAPSHOT_FIXTURE)
293
+ const halfLine = '{"v":"wf-run-v1","state":{"calls":[{'
294
+ await writeFile(path, fullLine + '\n' + halfLine)
295
+
296
+ const snap = await readRunSnapshot(path)
297
+ expect(snap).not.toBeUndefined()
298
+ const s = snap as Record<string, unknown>
299
+ expect(s.v).toBe('wf-run-v1') // 倒数第二行完整 NEW snapshot
300
+ expect(s.state).toBeDefined()
301
+ })
302
+
303
+ it('TC-w5-read-no-file:文件不存在返回 undefined(不抛错)', async () => {
304
+ const snap = await readRunSnapshot('/nonexistent/wf-xxx.jsonl')
305
+ expect(snap).toBeUndefined()
306
+ })
307
+
308
+ it('TC-w5-read-all-unparseable:全行 JSON.parse 失败返回 undefined', async () => {
309
+ dir = await mkdtemp(join(tmpdir(), 'wf-read-bad-'))
310
+ const path = join(dir, 'wf.jsonl')
311
+ await writeFile(path, '{bad json\n}{also bad')
312
+ const snap = await readRunSnapshot(path)
313
+ expect(snap).toBeUndefined()
314
+ })
315
+ })
316
+
317
+ // ============================================================
318
+ // 真实数据守卫(~/.pi/agent/workflow-state,CI 无本机数据时 skipIf 跳过)
319
+ // ============================================================
320
+
321
+ const REAL_WF_NEW = join(REAL_AGENT_DIR, 'workflow-state', 'wf-1785762350110-d297tr.jsonl')
322
+ const REAL_WF_OLD = join(REAL_AGENT_DIR, 'workflow-state', 'wf-skip-ok.jsonl')
323
+ const HAS_REAL_WF_NEW = existsSync(REAL_WF_NEW)
324
+ const HAS_REAL_WF_OLD = existsSync(REAL_WF_OLD)
325
+
326
+ describe.skipIf(!HAS_REAL_WF_NEW)('真实数据守卫 - NEW wf-state(wf-1785762350110-d297tr)', () => {
327
+ it('TC-w5-real-new-guard:readRunSnapshot+parseRunSnapshot 类型化 NEW 真实快照', async () => {
328
+ const snap = await readRunSnapshot(REAL_WF_NEW)
329
+ expect(snap).not.toBeUndefined()
330
+ const overview = parseRunSnapshot(snap, 'wf-1785762350110-d297tr', REAL_WF_NEW)
331
+ expect(overview).not.toBeNull()
332
+ expect(overview!.version).toBe('wf-run-v1')
333
+ expect(overview!.steps.length).toBeGreaterThanOrEqual(1)
334
+ // call 的 sessionFile 是真实绝对 .jsonl 路径(跳转入口)
335
+ expect(overview!.steps[0].sessionFile).toMatch(/\.jsonl$/)
336
+ expect(overview!.steps[0].sessionFile!.startsWith('/')).toBe(true)
337
+ expect(overview!.steps[0].sessionId).toBeTruthy()
338
+ }, 30000)
339
+ })
340
+
341
+ describe.skipIf(!HAS_REAL_WF_OLD)('真实数据守卫 - OLD wf-state(wf-skip-ok)', () => {
342
+ it('TC-w5-real-old-guard:readRunSnapshot+parseRunSnapshot 尽力解析 OLD 真实快照', async () => {
343
+ const snap = await readRunSnapshot(REAL_WF_OLD)
344
+ expect(snap).not.toBeUndefined()
345
+ const overview = parseRunSnapshot(snap, 'wf-skip-ok', REAL_WF_OLD)
346
+ expect(overview).not.toBeNull()
347
+ expect(overview!.version).toBe('legacy')
348
+ expect(overview!.status).toBe('running')
349
+ expect(overview!.script).toBe('workflow-wf-skip-ok') // name 映射
350
+ // OLD callCache value 无 sessionFile(探针 112 文件 0 sessionFile)
351
+ expect(overview!.steps[0].sessionFile).toBeUndefined()
352
+ }, 30000)
353
+ })