@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
@@ -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
  })
@@ -0,0 +1,282 @@
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 { 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
+ /** OLD 格式 fixture(对齐 wf-skip-ok.jsonl:无 v,callCache value 无 sessionFile/result)。 */
52
+ const OLD_SNAPSHOT_FIXTURE = {
53
+ runId: 'wf-old-ignore',
54
+ name: 'workflow-wf-skip-ok',
55
+ status: 'running',
56
+ callCache: [{ key: 7, value: { content: '', usage: { input: 0 } } }],
57
+ trace: [],
58
+ worker: 'agent-test',
59
+ startedAt: '2026-01-01T00:00:00Z',
60
+ budget: { usedTokens: 0, usedCost: 0 },
61
+ }
62
+
63
+ // ============================================================
64
+ // parseRunSnapshot(纯逻辑,TC-w5-parse-new/old/corrupt/malformed)
65
+ // ============================================================
66
+
67
+ describe('parseRunSnapshot', () => {
68
+ it('TC-w5-parse-new:NEW 格式 (v=wf-run-v1) 字段映射,runId/stateFile 参数透传', () => {
69
+ const overview = parseRunSnapshot(NEW_SNAPSHOT_FIXTURE, 'wf-link-runid', '/abs/wf.jsonl')
70
+ expect(overview).not.toBeNull()
71
+ // 参数透传(不读 snapshot.runId)
72
+ expect(overview!.runId).toBe('wf-link-runid')
73
+ expect(overview!.stateFile).toBe('/abs/wf.jsonl')
74
+ // 顶层字段
75
+ expect(overview!.version).toBe('wf-run-v1')
76
+ expect(overview!.status).toBe('done')
77
+ expect(overview!.reason).toBe('completed')
78
+ expect(overview!.script).toBe('thinkinglevel-probe') // spec.scriptName 优先于 name
79
+ expect(overview!.startedAt).toBe('2026-08-03T13:05:50.111Z')
80
+ expect(overview!.completedAt).toBe('2026-08-03T13:05:55.384Z')
81
+ // budget 透传
82
+ expect(overview!.budget.usedTokens).toBe(11060.24)
83
+ expect(overview!.budget.usedCost).toBe(0)
84
+ expect(overview!.budget.totalCallCount).toBe(1)
85
+ expect(overview!.budget.maxTokens).toBe(100000)
86
+ // steps
87
+ expect(overview!.steps).toHaveLength(1)
88
+ const step = overview!.steps[0]
89
+ expect(step.index).toBe(0)
90
+ expect(step.status).toBe('done')
91
+ expect(step.description).toBe('step-0')
92
+ expect(step.model).toBe('deepseek-router/ds-pro')
93
+ expect(step.thinkingLevel).toBe('high')
94
+ expect(step.attempts).toBe(1)
95
+ expect(step.durationMs).toBe(1234)
96
+ expect(step.sessionId).toBe('019xxx')
97
+ expect(step.sessionFile).toBe('/abs/session.jsonl')
98
+ expect(step.contentPreview).toBe('PROBE-OK')
99
+ })
100
+
101
+ it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
102
+ const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
103
+ expect(overview).not.toBeNull()
104
+ expect(overview!.version).toBe('legacy')
105
+ expect(overview!.status).toBe('running') // 顶层 status
106
+ expect(overview!.script).toBe('workflow-wf-skip-ok') // name 映射
107
+ expect(overview!.startedAt).toBe('2026-01-01T00:00:00Z')
108
+ expect(overview!.budget.usedTokens).toBe(0)
109
+ expect(overview!.budget.usedCost).toBe(0)
110
+ // steps
111
+ expect(overview!.steps).toHaveLength(1)
112
+ const step = overview!.steps[0]
113
+ expect(step.index).toBe(0) // callCache 顺序索引
114
+ expect(step.status).toBe('pending') // content='' 空串不算完成标志 → pending
115
+ expect(step.sessionFile).toBeUndefined() // OLD 未持久化
116
+ expect(step.contentPreview).toBe('') // value.content='' 仍提取
117
+ })
118
+
119
+ it('TC-w5-parse-corrupt-nonobject:非对象输入(null/undefined/string/number/array)返回 null', () => {
120
+ for (const bad of [null, undefined, 'string', 42, [1, 2, 3]] as unknown[]) {
121
+ expect(parseRunSnapshot(bad, 'r', 's')).toBeNull()
122
+ }
123
+ })
124
+
125
+ 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()
128
+ // (b) 无 v 无 callCache 无 status(异构对象)
129
+ expect(parseRunSnapshot({ foo: 'bar', baz: 1 }, 'r', 's')).toBeNull()
130
+ })
131
+ })
132
+
133
+ // ============================================================
134
+ // renderWorkflowOverview(纯逻辑,TC-w5-render-new/old)
135
+ // ============================================================
136
+
137
+ describe('renderWorkflowOverview', () => {
138
+ it('TC-w5-render-new:NEW 概览含 run 头/budget/steps,step 含 call sessionId 截断 + sessionFile 绝对路径', () => {
139
+ const overview = {
140
+ runId: 'wf-run-1',
141
+ stateFile: '/abs/state.jsonl',
142
+ status: 'done',
143
+ version: 'wf-run-v1' as const,
144
+ script: 'probe',
145
+ startedAt: '2026-01-01T00:00:00Z',
146
+ completedAt: '2026-01-01T00:01:00Z',
147
+ budget: { usedTokens: 11060, usedCost: 0, totalCallCount: 2, maxTokens: 100000 },
148
+ steps: [
149
+ {
150
+ index: 0,
151
+ status: 'done' as const,
152
+ model: 'm1',
153
+ durationMs: 100,
154
+ sessionId: '019aaa',
155
+ sessionFile: '/abs/a.jsonl',
156
+ },
157
+ {
158
+ index: 1,
159
+ status: 'done' as const,
160
+ model: 'm1',
161
+ durationMs: 200,
162
+ sessionId: '019bbb',
163
+ sessionFile: '/abs/b.jsonl',
164
+ },
165
+ ],
166
+ }
167
+ const out = renderWorkflowOverview(overview)
168
+ // 头行
169
+ expect(out).toContain('run: wf-run-1')
170
+ expect(out).toContain('[done]')
171
+ // budget 行
172
+ expect(out).toContain('budget:')
173
+ expect(out).toContain('used=11060tok')
174
+ expect(out).toContain('calls=2')
175
+ expect(out).toContain('max=100000tok')
176
+ // 每个 step 行
177
+ expect(out).toContain('#0')
178
+ expect(out).toContain('#1')
179
+ expect(out).toContain('model=m1')
180
+ expect(out).toContain('100ms')
181
+ expect(out).toContain('call=019aaa') // sessionId 截断(6 字符 <= 12)
182
+ expect(out).toContain('/abs/a.jsonl') // sessionFile 绝对路径(跳转入口)
183
+ expect(out).toContain('/abs/b.jsonl')
184
+ })
185
+
186
+ it('TC-w5-render-old:OLD 概览 step sessionFile 缺标「(无 sessionFile,OLD 格式未持久化)」', () => {
187
+ const overview = {
188
+ runId: 'wf-old-1',
189
+ stateFile: '/abs/old.jsonl',
190
+ status: 'running',
191
+ version: 'legacy' as const,
192
+ budget: { usedTokens: 0 },
193
+ steps: [{ index: 0, status: 'pending' as const, sessionFile: undefined }],
194
+ }
195
+ const out = renderWorkflowOverview(overview)
196
+ expect(out).toContain('run:')
197
+ expect(out).toContain('[running]')
198
+ expect(out).toContain('budget:')
199
+ expect(out).toContain('#0')
200
+ expect(out).toContain('[pending]')
201
+ expect(out).toContain('(无 sessionFile,OLD 格式未持久化)')
202
+ // 不输出 'sessionFile=undefined' 字面量
203
+ expect(out).not.toContain('sessionFile=undefined')
204
+ })
205
+ })
206
+
207
+ // ============================================================
208
+ // readRunSnapshot(IO,TC-w5-read-tail-fallback/no-file/all-unparseable)
209
+ // ============================================================
210
+
211
+ describe('readRunSnapshot', () => {
212
+ let dir: string
213
+ afterEach(async () => {
214
+ if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {})
215
+ })
216
+
217
+ it('TC-w5-read-tail-fallback:末行半截 JSON 回退倒数第二行完整快照', async () => {
218
+ dir = await mkdtemp(join(tmpdir(), 'wf-read-tail-'))
219
+ const path = join(dir, 'wf.jsonl')
220
+ // 第 1 行完整 NEW snapshot + 半截第 2 行(模拟 rewrite 中点)
221
+ const fullLine = JSON.stringify(NEW_SNAPSHOT_FIXTURE)
222
+ const halfLine = '{"v":"wf-run-v1","state":{"calls":[{'
223
+ await writeFile(path, fullLine + '\n' + halfLine)
224
+
225
+ const snap = await readRunSnapshot(path)
226
+ expect(snap).not.toBeUndefined()
227
+ const s = snap as Record<string, unknown>
228
+ expect(s.v).toBe('wf-run-v1') // 倒数第二行完整 NEW snapshot
229
+ expect(s.state).toBeDefined()
230
+ })
231
+
232
+ it('TC-w5-read-no-file:文件不存在返回 undefined(不抛错)', async () => {
233
+ const snap = await readRunSnapshot('/nonexistent/wf-xxx.jsonl')
234
+ expect(snap).toBeUndefined()
235
+ })
236
+
237
+ it('TC-w5-read-all-unparseable:全行 JSON.parse 失败返回 undefined', async () => {
238
+ dir = await mkdtemp(join(tmpdir(), 'wf-read-bad-'))
239
+ const path = join(dir, 'wf.jsonl')
240
+ await writeFile(path, '{bad json\n}{also bad')
241
+ const snap = await readRunSnapshot(path)
242
+ expect(snap).toBeUndefined()
243
+ })
244
+ })
245
+
246
+ // ============================================================
247
+ // 真实数据守卫(~/.pi/agent/workflow-state,CI 无本机数据时 skipIf 跳过)
248
+ // ============================================================
249
+
250
+ const REAL_WF_NEW = join(REAL_AGENT_DIR, 'workflow-state', 'wf-1785762350110-d297tr.jsonl')
251
+ const REAL_WF_OLD = join(REAL_AGENT_DIR, 'workflow-state', 'wf-skip-ok.jsonl')
252
+ const HAS_REAL_WF_NEW = existsSync(REAL_WF_NEW)
253
+ const HAS_REAL_WF_OLD = existsSync(REAL_WF_OLD)
254
+
255
+ describe.skipIf(!HAS_REAL_WF_NEW)('真实数据守卫 - NEW wf-state(wf-1785762350110-d297tr)', () => {
256
+ it('TC-w5-real-new-guard:readRunSnapshot+parseRunSnapshot 类型化 NEW 真实快照', async () => {
257
+ const snap = await readRunSnapshot(REAL_WF_NEW)
258
+ expect(snap).not.toBeUndefined()
259
+ const overview = parseRunSnapshot(snap, 'wf-1785762350110-d297tr', REAL_WF_NEW)
260
+ expect(overview).not.toBeNull()
261
+ expect(overview!.version).toBe('wf-run-v1')
262
+ expect(overview!.steps.length).toBeGreaterThanOrEqual(1)
263
+ // call 的 sessionFile 是真实绝对 .jsonl 路径(跳转入口)
264
+ expect(overview!.steps[0].sessionFile).toMatch(/\.jsonl$/)
265
+ expect(overview!.steps[0].sessionFile!.startsWith('/')).toBe(true)
266
+ expect(overview!.steps[0].sessionId).toBeTruthy()
267
+ }, 30000)
268
+ })
269
+
270
+ describe.skipIf(!HAS_REAL_WF_OLD)('真实数据守卫 - OLD wf-state(wf-skip-ok)', () => {
271
+ it('TC-w5-real-old-guard:readRunSnapshot+parseRunSnapshot 尽力解析 OLD 真实快照', async () => {
272
+ const snap = await readRunSnapshot(REAL_WF_OLD)
273
+ expect(snap).not.toBeUndefined()
274
+ const overview = parseRunSnapshot(snap, 'wf-skip-ok', REAL_WF_OLD)
275
+ expect(overview).not.toBeNull()
276
+ expect(overview!.version).toBe('legacy')
277
+ expect(overview!.status).toBe('running')
278
+ expect(overview!.script).toBe('workflow-wf-skip-ok') // name 映射
279
+ // OLD callCache value 无 sessionFile(探针 112 文件 0 sessionFile)
280
+ expect(overview!.steps[0].sessionFile).toBeUndefined()
281
+ }, 30000)
282
+ })