@zhushanwen/pi-session-reader 0.1.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.
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseSessionContent, parseSessionFile } from '../core/parser.js'
3
+ import type { Entry } from '../core/parser.js'
4
+ import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
5
+
6
+ /** 构造单行 JSONL entry 字符串 */
7
+ function line(obj: Record<string, unknown>): string {
8
+ return JSON.stringify(obj)
9
+ }
10
+
11
+ describe('parseSessionContent', () => {
12
+ it('正常多类型 entry 全解析,字段完整保留', () => {
13
+ const content = [
14
+ line({ type: 'session', id: 's1', parentId: null, timestamp: 't0', cwd: '/x', parentSession: '/p.jsonl', version: 3 }),
15
+ line({ type: 'message', id: 'm1', parentId: 's1', timestamp: 't1', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }),
16
+ line({ type: 'message', id: 'm2', parentId: 'm1', timestamp: 't2', message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [{ name: 'bash', args: {} }] } }),
17
+ line({ type: 'compaction', id: 'c1', parentId: 'm2', timestamp: 't3', summary: { kept: 5 }, firstKeptEntryId: 'm1' }),
18
+ line({ type: 'custom', id: 'u1', parentId: 'c1', timestamp: 't4', customType: 'subagent-identity', data: { slug: 'fix' } }),
19
+ ].join('\n')
20
+
21
+ const result = parseSessionContent(content)
22
+
23
+ expect(result.skippedLines).toBe(0)
24
+ expect(result.lastLinePartial).toBe(false)
25
+ expect(result.entries).toHaveLength(5)
26
+
27
+ const [s, u, a, c, custom] = result.entries
28
+
29
+ // session header:cwd/parentSession 保留;接口未列的 version 不暴露
30
+ expect(s.type).toBe('session')
31
+ expect(s.id).toBe('s1')
32
+ expect(s.parentId).toBeNull()
33
+ expect(s.cwd).toBe('/x')
34
+ expect(s.parentSession).toBe('/p.jsonl')
35
+ expect((s as Entry).version).toBeUndefined()
36
+
37
+ // user message
38
+ expect(u.type).toBe('message')
39
+ expect(u.id).toBe('m1')
40
+ expect(u.parentId).toBe('s1')
41
+ expect(u.message?.role).toBe('user')
42
+ expect(u.message?.content).toEqual([{ type: 'text', text: 'hi' }])
43
+
44
+ // assistant message + toolCalls
45
+ expect(a.message?.role).toBe('assistant')
46
+ expect(a.message?.toolCalls).toEqual([{ name: 'bash', args: {} }])
47
+
48
+ // compaction summary
49
+ expect(c.type).toBe('compaction')
50
+ expect(c.summary).toEqual({ kept: 5 })
51
+
52
+ // custom
53
+ expect(custom.type).toBe('custom')
54
+ expect(custom.customType).toBe('subagent-identity')
55
+ expect(custom.data).toEqual({ slug: 'fix' })
56
+ })
57
+
58
+ it('中间夹坏行:skippedLines 正确,坏行前后 entry 保留', () => {
59
+ const content = [
60
+ line({ type: 'message', id: 'a', parentId: null }),
61
+ 'THIS IS NOT JSON {{{',
62
+ line({ type: 'message', id: 'b', parentId: 'a' }),
63
+ ].join('\n')
64
+
65
+ const result = parseSessionContent(content)
66
+
67
+ expect(result.entries).toHaveLength(2)
68
+ expect(result.entries[0].id).toBe('a')
69
+ expect(result.entries[1].id).toBe('b')
70
+ expect(result.skippedLines).toBe(1)
71
+ // 最后一行解析成功 → 非 partial
72
+ expect(result.lastLinePartial).toBe(false)
73
+ })
74
+
75
+ it('空字符串:entries 为空,skippedLines=0,非 partial', () => {
76
+ const result = parseSessionContent('')
77
+
78
+ expect(result.entries).toEqual([])
79
+ expect(result.skippedLines).toBe(0)
80
+ expect(result.lastLinePartial).toBe(false)
81
+ expect(result.totalBytes).toBe(0)
82
+ })
83
+
84
+ it('末尾换行不产生幽灵行(不计 skipped/partial)', () => {
85
+ const content = line({ type: 'message', id: 'a', parentId: null }) + '\n'
86
+ const result = parseSessionContent(content)
87
+
88
+ expect(result.entries).toHaveLength(1)
89
+ expect(result.skippedLines).toBe(0)
90
+ expect(result.lastLinePartial).toBe(false)
91
+ })
92
+
93
+ it('最后一行是半 JSON:lastLinePartial=true,仍计入 skippedLines', () => {
94
+ const content =
95
+ line({ type: 'message', id: 'a', parentId: null }) + '\n' + '{"type":"message","id":"x"'
96
+ const result = parseSessionContent(content)
97
+
98
+ expect(result.entries).toHaveLength(1)
99
+ expect(result.entries[0].id).toBe('a')
100
+ expect(result.skippedLines).toBe(1)
101
+ expect(result.lastLinePartial).toBe(true)
102
+ })
103
+
104
+ it('JSON 合法但缺必填结构字段(type/id)视为坏行', () => {
105
+ const content = [
106
+ line({ type: 'message', id: 'ok', parentId: null }),
107
+ line({ foo: 'bar' }), // 缺 type/id
108
+ '{"id":"no_type"}', // 缺 type
109
+ ].join('\n')
110
+
111
+ const result = parseSessionContent(content)
112
+
113
+ expect(result.entries).toHaveLength(1)
114
+ expect(result.entries[0].id).toBe('ok')
115
+ expect(result.skippedLines).toBe(2)
116
+ // 最后那条坏行也是最后一行 → partial
117
+ expect(result.lastLinePartial).toBe(true)
118
+ })
119
+
120
+ it('custom entry 无顶层 id 时 fallback 到 data.id(pi subagent-identity 等格式)', () => {
121
+ const content = line({
122
+ type: 'custom',
123
+ customType: 'subagent-identity',
124
+ data: { id: 'sa-abc123', rootSessionId: 'sess-1', slug: 'fix' },
125
+ })
126
+ const result = parseSessionContent(content)
127
+
128
+ 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)
132
+ })
133
+
134
+ it('session header 无 parentId → 归一化为 null(root 判定)', () => {
135
+ const content = '{"type":"session","id":"root","timestamp":"t"}'
136
+ const result = parseSessionContent(content)
137
+
138
+ expect(result.entries).toHaveLength(1)
139
+ expect(result.entries[0].parentId).toBeNull()
140
+ })
141
+
142
+ it('message 缺/非法 role → 丢弃 message 字段但保留 entry', () => {
143
+ const content = [
144
+ line({ type: 'message', id: 'm1', parentId: null, message: { role: 'system', content: 'x' } }),
145
+ line({ type: 'message', id: 'm2', parentId: null, message: { content: 'x' } }),
146
+ ].join('\n')
147
+
148
+ const result = parseSessionContent(content)
149
+
150
+ expect(result.entries).toHaveLength(2)
151
+ expect(result.entries[0].message).toBeUndefined()
152
+ expect(result.entries[1].message).toBeUndefined()
153
+ })
154
+ })
155
+
156
+ describe('parseSessionFile', () => {
157
+ it.skipIf(!HAS_REAL_SESSION)('真实 session 019e6c96:1204 entries / 0 skipped / 非 partial', async () => {
158
+ const result = await parseSessionFile(REAL_SESSION)
159
+
160
+ expect(result.entries).toHaveLength(1204)
161
+ expect(result.skippedLines).toBe(0)
162
+ expect(result.lastLinePartial).toBe(false)
163
+ // 5.4MB 量级
164
+ expect(result.totalBytes).toBeGreaterThan(5_000_000)
165
+ })
166
+ })
@@ -0,0 +1,53 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { execSync } from 'node:child_process'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * 真实 pi agent 数据常量 + 存在性探测(MF-1:CI 无本机 ~/.pi/agent 目录,
7
+ * 依赖真实数据的用例必须 skipIf 守卫,否则 CI `pnpm extensions:test` 必红)。
8
+ *
9
+ * 用法:测试文件 `import { REAL_AGENT_DIR, HAS_E6 } from './real-data.js'`,
10
+ * 真实数据用例套 `it.skipIf(!HAS_X)` / `describe.skipIf(!HAS_X)`;
11
+ * fixture 用例保持无条件跑。
12
+ *
13
+ * 本文件无 .test.ts 后缀,vitest include 只收集 `src/__tests__/` 下以 .test.ts 结尾的文件,不收集本文件。
14
+ */
15
+
16
+ /** 真实 pi agent 目录(本机),用于集成测试。 */
17
+ export const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
18
+
19
+ /** 5.4MB / 32 turn / 1204 entry 的真实 session(feat-plugin-arch-3 目录)。 */
20
+ export const E6 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
21
+ /** 真实 fork 家族根(fork 子代 019fe632,隔代 subagent 019fe635 挂在 019fe632 下)。 */
22
+ export const FAM = '019fe620-8ae1-78a7-b76a-43a1ba4cc3c7'
23
+
24
+ /** E6 的完整文件路径(parser/turns/render 直接 parseSessionFile 用)。 */
25
+ export const REAL_SESSION = join(
26
+ REAL_AGENT_DIR,
27
+ 'sessions',
28
+ '--Users-zhushanwen-Code-xyz-agent-workspace-feat-plugin-arch-3--',
29
+ `2026-05-28T03-17-12-844Z_${E6}.jsonl`,
30
+ )
31
+
32
+ /** 同步探测真实 session 文件是否存在(不存在则 skip,避免在无该数据的机器上硬失败)。 */
33
+ export function hasRealSession(sid: string): boolean {
34
+ if (!existsSync(REAL_AGENT_DIR)) return false
35
+ try {
36
+ return (
37
+ execSync(
38
+ `find ${REAL_AGENT_DIR}/sessions -name '*${sid}*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
39
+ { encoding: 'utf8' },
40
+ ).trim().length > 0
41
+ )
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ export const HAS_REAL_AGENT_DIR = existsSync(REAL_AGENT_DIR)
48
+ export const HAS_REAL_SUBAGENTS_DIR = existsSync(join(REAL_AGENT_DIR, 'subagents'))
49
+ export const HAS_REAL_SESSION = existsSync(REAL_SESSION)
50
+ export const HAS_E6 = hasRealSession(E6)
51
+ export const HAS_FAM = hasRealSession(FAM)
52
+ /** tool-handler 的 handleSessionRead 套件同时依赖 E6 + FAM。 */
53
+ export const HAS_REAL = HAS_E6 && HAS_FAM
@@ -0,0 +1,367 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderOutline, renderExpand, renderDetail, type ToolResultSummaryEntry } from '../core/render.js'
3
+ import type { Entry } from '../core/parser.js'
4
+ import type { Turn } from '../core/turns.js'
5
+ import type { TreeView } from '../core/tree.js'
6
+ import { parseSessionFile } from '../core/parser.js'
7
+ import { buildTreeView } from '../core/tree.js'
8
+ import { segmentTurns } from '../core/turns.js'
9
+ import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
10
+
11
+ // ---- 构造助手 ----
12
+
13
+ function uEntry(id: string, text: string): Entry {
14
+ return {
15
+ type: 'message',
16
+ id,
17
+ parentId: null,
18
+ message: { role: 'user', content: [{ type: 'text', text }] },
19
+ }
20
+ }
21
+
22
+ function aEntry(
23
+ id: string,
24
+ text: string,
25
+ toolCalls?: Array<{ name: string; id?: string; arguments?: Record<string, unknown> }>,
26
+ thinking?: string,
27
+ ): Entry {
28
+ // v2:toolCalls 进 content 的 toolCall block(真实结构,probe 实测 519),不再设 message.toolCalls
29
+ const content: unknown[] = [{ type: 'text', text }]
30
+ if (thinking !== undefined) content.push({ type: 'thinking', thinking })
31
+ if (toolCalls !== undefined) {
32
+ for (const tc of toolCalls) {
33
+ content.push({
34
+ type: 'toolCall',
35
+ id: tc.id ?? `call_${tc.name}`,
36
+ name: tc.name,
37
+ arguments: tc.arguments ?? {},
38
+ })
39
+ }
40
+ }
41
+ return { type: 'message', id, parentId: null, message: { role: 'assistant', content } }
42
+ }
43
+
44
+ function tEntry(
45
+ id: string,
46
+ content: string,
47
+ opts?: { toolCallId?: string; toolName?: string },
48
+ ): Entry {
49
+ // v2:content 保持 string(测试简化,toolResultText 对 string/数组都处理);
50
+ // 加 toolCallId/toolName 供 O2 类型化摘要关联测试
51
+ const message: NonNullable<Entry['message']> = { role: 'toolResult', content }
52
+ if (opts?.toolName !== undefined) message.toolName = opts.toolName
53
+ if (opts?.toolCallId !== undefined) message.toolCallId = opts.toolCallId
54
+ return { type: 'message', id, parentId: null, message }
55
+ }
56
+
57
+ function turn(
58
+ idx: number,
59
+ entries: Entry[],
60
+ opts: { userEntry?: Entry; isCompaction?: boolean; startTime?: string } = {},
61
+ ): Turn {
62
+ const t: Turn = {
63
+ index: idx,
64
+ entries,
65
+ userEntry: opts.userEntry,
66
+ isCompaction: opts.isCompaction ?? false,
67
+ }
68
+ if (opts.startTime !== undefined) t.startTime = opts.startTime
69
+ return t
70
+ }
71
+
72
+ function emptyTree(): TreeView {
73
+ return { leafPath: [], branches: new Map(), orphans: [] }
74
+ }
75
+
76
+ describe('renderOutline', () => {
77
+ it('1. 预算充足 → 全部字段完整渲染(userBrief 截断 / toolSummary 聚合 / omittedBytes 正确)', () => {
78
+ const t = turn(0, [
79
+ uEntry('U', 'do something'),
80
+ aEntry('A', 'sure', [{ name: 'bash' }, { name: 'bash' }, { name: 'read' }, { name: 'read' }], 'secret'),
81
+ tEntry('T', 'output'),
82
+ ], { userEntry: uEntry('U', 'do something') })
83
+
84
+ const result = renderOutline([t], emptyTree(), { budget: 2000 })
85
+ const b = result.turns[0]
86
+
87
+ expect(b.userBrief).toBe('do something') // 12 chars,未截断
88
+ expect(b.toolSummary).toBe('bash×2,read×2')
89
+ expect(b.assistantBrief).toBe('sure')
90
+ // thinking 'secret'(6B) + toolResult 'output'(6B)
91
+ expect(b.omittedBytes).toBe(12)
92
+ expect(result.truncated).toBeUndefined()
93
+ })
94
+
95
+ it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
96
+ // L1 行不含 assistantBrief(design §3.5 算法1 step2);budget=40, 3 turns → perTurnCharBudget≈53
97
+ // 行 = head + userBrief(10) + toolSummary(20 个工具名≈63 chars) → ~80 > 53 → 砍 toolSummary
98
+ const toolCalls = Array.from({ length: 20 }, (_, k) => ({ name: `t${String(k).padStart(2, '0')}` }))
99
+ const turns = [0, 1, 2].map((i) =>
100
+ turn(i, [uEntry(`U${i}`, 'u'.repeat(10)), aEntry(`A${i}`, 'a'.repeat(10), toolCalls)], {
101
+ userEntry: uEntry(`U${i}`, 'u'.repeat(10)),
102
+ }),
103
+ )
104
+ const result = renderOutline(turns, emptyTree(), { budget: 40 })
105
+
106
+ expect(result.turns).toHaveLength(3)
107
+ for (const b of result.turns) {
108
+ expect(b.toolSummary).toBe('') // 被砍(降级)
109
+ expect(b.userBrief).toBe('u'.repeat(10)) // 骨架保留
110
+ }
111
+ expect(result.truncated).toBeUndefined()
112
+ })
113
+
114
+ it('3. 总超预算 → truncated 计数 > 0(从尾部丢弃)', () => {
115
+ // budget=10, 5 turns → 每行降级到骨架 17 chars;total 85/4=21>10 → 截断保留 2,truncated=3
116
+ const turns = [0, 1, 2, 3, 4].map((i) =>
117
+ turn(i, [uEntry(`U${i}`, 'u'.repeat(10)), aEntry(`A${i}`, 'a'.repeat(10))], {
118
+ userEntry: uEntry(`U${i}`, 'u'.repeat(10)),
119
+ }),
120
+ )
121
+ const result = renderOutline(turns, emptyTree(), { budget: 10 })
122
+
123
+ expect(result.truncated).toBe(3)
124
+ expect(result.turns).toHaveLength(2)
125
+ })
126
+
127
+ it('4. toolSummary 聚合:同名计数 ×N,不同名逗号分隔', () => {
128
+ const t = turn(
129
+ 0,
130
+ [uEntry('U', 'q'), aEntry('A', 'r', [{ name: 'bash' }, { name: 'bash' }, { name: 'read' }, { name: 'read' }, { name: 'edit' }])],
131
+ { userEntry: uEntry('U', 'q') },
132
+ )
133
+ const result = renderOutline([t], emptyTree(), { budget: 2000 })
134
+ expect(result.turns[0].toolSummary).toBe('bash×2,read×2,edit')
135
+ })
136
+
137
+ it('5. omittedBytes = toolResult content + thinking 字节和', () => {
138
+ const t = turn(
139
+ 0,
140
+ [uEntry('U', 'q'), aEntry('A', 'r', undefined, 'th'), tEntry('T', 'out')],
141
+ { userEntry: uEntry('U', 'q') },
142
+ )
143
+ const result = renderOutline([t], emptyTree(), { budget: 2000 })
144
+ // thinking 'th'(2B) + toolResult 'out'(3B) = 5
145
+ expect(result.turns[0].omittedBytes).toBe(5)
146
+ })
147
+
148
+ it('6. granularity:entry → 不聚合,每 entry 一行', () => {
149
+ const turns = [
150
+ turn(0, [uEntry('U0', 'a'), aEntry('A0', 'b')], { userEntry: uEntry('U0', 'a') }),
151
+ turn(1, [uEntry('U1', 'c')], { userEntry: uEntry('U1', 'c') }),
152
+ ]
153
+ const result = renderOutline(turns, emptyTree(), { granularity: 'entry', budget: 2000 })
154
+ // 3 leaf entries → 3 行
155
+ expect(result.turns).toHaveLength(3)
156
+ expect(result.turns.map((b) => b.index)).toEqual([0, 1, 2])
157
+ })
158
+
159
+ it('7. allBranches:true → forkPoint 处标注 branch', () => {
160
+ const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
161
+ const userEntry = uEntry('U', 'hi')
162
+ const turns = [turn(0, [userEntry], { userEntry })]
163
+
164
+ const withBranches = renderOutline(turns, tree, { allBranches: true })
165
+ expect(withBranches.turns[0].branch).toBe('U')
166
+
167
+ const withoutBranches = renderOutline(turns, tree, { allBranches: false })
168
+ expect(withoutBranches.turns[0].branch).toBeUndefined()
169
+ })
170
+
171
+ it.skipIf(!HAS_REAL_SESSION)('8. 真实 019e6c96:outline tokenEstimate <= 1500 + assistantBrief/toolSummary 非空(v2 O1)', async () => {
172
+ // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 变长,阈值 600→1500(design §3.3 D4)
173
+ const parsed = await parseSessionFile(REAL_SESSION)
174
+ const tree = buildTreeView(parsed.entries)
175
+ const turns = segmentTurns(parsed.entries, new Set(tree.leafPath))
176
+ const result = renderOutline(turns, tree, { budget: 2000 })
177
+
178
+ expect(result.turns.length).toBe(32)
179
+ expect(result.tokenEstimate).toBeLessThanOrEqual(1500)
180
+ // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示真实工具(修 v1 恒空 bug)
181
+ expect(result.turns.some((b) => b.assistantBrief !== '')).toBe(true)
182
+ expect(result.turns.some((b) => b.toolSummary !== '')).toBe(true)
183
+ // stats 完整性
184
+ expect(result.stats.totalTurns).toBe(32)
185
+ // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
186
+ // 准确值由 M2 工具层用 ParseResult.totalEntries 覆盖。M1 验量级。
187
+ expect(result.stats.totalEntries).toBeGreaterThan(1000)
188
+ })
189
+ })
190
+
191
+ describe('renderExpand', () => {
192
+ it('单轮展开:header 含 entry 数 + 每个 entry brief', () => {
193
+ const t = turn(
194
+ 0,
195
+ [uEntry('U', 'hello world'), aEntry('A', 'ok'), tEntry('T', 'result text')],
196
+ { userEntry: uEntry('U', 'hello world'), startTime: '2026-05-28T03:17:12.844Z' },
197
+ )
198
+ const out = renderExpand(t)
199
+
200
+ expect(out.turn).toContain('3 entries')
201
+ expect(out.turn).toContain('03:17')
202
+ expect(out.entries).toHaveLength(3)
203
+ expect(out.entries[0]).toMatchObject({ index: 0, type: 'message', role: 'user' })
204
+ expect(out.entries[0].brief).toBe('hello world')
205
+ expect(out.entries[1].role).toBe('assistant')
206
+ expect(out.entries[2].role).toBe('toolResult')
207
+ // toolResult 的 omittedBytes = content 字节
208
+ expect(out.entries[2].omittedBytes).toBe(Buffer.byteLength('result text', 'utf8'))
209
+ })
210
+
211
+ it('v2 O2:toolResult 类型化摘要(bash: <cmd> (N行),toolCallId 关联取 args)', () => {
212
+ // 构造带 toolCallId 关联的 turn:assistant 调 bash,toolResult 靠 toolCallId 关联回去取 command
213
+ const tcId = 'call_bash1'
214
+ const t = turn(
215
+ 0,
216
+ [
217
+ uEntry('U', '查一下文件'),
218
+ aEntry('A', '好的', [{ name: 'bash', id: tcId, arguments: { command: 'ls -la src/' } }]),
219
+ tEntry('T', 'file1\nfile2\nfile3', { toolCallId: tcId, toolName: 'bash' }),
220
+ ],
221
+ { userEntry: uEntry('U', '查一下文件') },
222
+ )
223
+ const out = renderExpand(t)
224
+ const trBrief = out.entries.find((e) => e.role === 'toolResult')!
225
+ // 类型化摘要:bash: <cmd> (N行),不是结果文本前 100 字(file1/file2...)
226
+ expect(trBrief.brief).toContain('bash: ls -la src/')
227
+ expect(trBrief.brief).toContain('3行')
228
+ expect(trBrief.brief).not.toContain('file1')
229
+ })
230
+
231
+ it('v2 O2:read/edit/write 等工具的类型化摘要', () => {
232
+ const t = turn(
233
+ 0,
234
+ [
235
+ uEntry('U', '改代码'),
236
+ aEntry('A1', '读文件', [{ name: 'read', id: 'r1', arguments: { path: '/a/b/src/index.ts' } }]),
237
+ tEntry('TR1', 'content', { toolCallId: 'r1', toolName: 'read' }),
238
+ aEntry('A2', '编辑', [{ name: 'edit', id: 'e1', arguments: { path: '/a/b/foo.ts', edits: [{}, {}] } }]),
239
+ tEntry('TR2', 'ok', { toolCallId: 'e1', toolName: 'edit' }),
240
+ aEntry('A3', '写', [{ name: 'write', id: 'w1', arguments: { path: '/a/b/out.txt', content: 'x'.repeat(2048) } }]),
241
+ tEntry('TR3', 'ok', { toolCallId: 'w1', toolName: 'write' }),
242
+ ],
243
+ { userEntry: uEntry('U', '改代码') },
244
+ )
245
+ const out = renderExpand(t)
246
+ const briefs = out.entries.filter((e) => e.role === 'toolResult').map((e) => e.brief)
247
+ // read: read: index.ts (NKB)(结果 KB)
248
+ expect(briefs[0]).toMatch(/^read: index\.ts \(\d+KB\)$/) // 点号转义
249
+ // edit: edit: foo.ts (2 blocks)(参数 blocks)
250
+ expect(briefs[1]).toBe('edit: foo.ts (2 blocks)')
251
+ // write: write: out.txt (NKB)(参数 content KB)
252
+ expect(briefs[2]).toMatch(/^write: out\.txt \(\d+KB\)$/) // 修正:out.txt
253
+ })
254
+ })
255
+
256
+ describe('renderDetail', () => {
257
+ it('默认 toolResult 摘要态(O3)+ thinking 剥离;includeToolResult/includeThinking 取回', () => {
258
+ const t = turn(0, [
259
+ uEntry('U', 'q'),
260
+ aEntry('A', 'visible', undefined, 'hidden-thinking'),
261
+ tEntry('T', 'tool-output'),
262
+ ], { userEntry: uEntry('U', 'q') })
263
+
264
+ // v2 O3:默认不再 continue 跳过 toolResult,条目数不减少(toolResult 变摘要态)
265
+ const defaultOut = renderDetail([t])
266
+ expect(defaultOut).toHaveLength(3)
267
+ expect(defaultOut.some((e) => e.type === 'toolResultSummary')).toBe(true)
268
+ // thinking 块被剥离(assistant 副本,无 thinking)
269
+ const entries = defaultOut.filter((e): e is Entry => e.type !== 'toolResultSummary')
270
+ const assistantDefault = entries.find((e) => e.message?.role === 'assistant')!
271
+ expect(extractTextForTest(assistantDefault.message!.content)).toBe('visible')
272
+
273
+ // includeToolResult:toolResult 原文 entry 回来(非摘要态)
274
+ const withTool = renderDetail([t], { includeToolResult: true })
275
+ expect(withTool).toHaveLength(3)
276
+ expect(
277
+ withTool.some(
278
+ (e): e is Entry => e.type !== 'toolResultSummary' && e.message?.role === 'toolResult',
279
+ ),
280
+ ).toBe(true)
281
+
282
+ // includeThinking:thinking 块回来
283
+ const withThinking = renderDetail([t], { includeThinking: true })
284
+ const entriesT = withThinking.filter((e): e is Entry => e.type !== 'toolResultSummary')
285
+ const assistantThinking = entriesT.find((e) => e.message?.role === 'assistant')!
286
+ expect(extractTextForTest(assistantThinking.message!.content)).toBe('visible')
287
+ expect(extractThinkingForTest(assistantThinking.message!.content)).toBe('hidden-thinking')
288
+ })
289
+
290
+ it('v2 O3:renderDetail 默认摘要态 + includeToolResult 全文', () => {
291
+ const tcId = 'call_r1'
292
+ const t = turn(
293
+ 0,
294
+ [
295
+ uEntry('U', 'q'),
296
+ aEntry('A', 'r', [{ name: 'read', id: tcId, arguments: { path: '/x/y/f.ts' } }]),
297
+ tEntry('T', 'line1\nline2\nline3\nline4', { toolCallId: tcId, toolName: 'read' }),
298
+ ],
299
+ { userEntry: uEntry('U', 'q') },
300
+ )
301
+
302
+ // 默认摘要态
303
+ const def = renderDetail([t])
304
+ expect(def).toHaveLength(3)
305
+ const summary = def.find(
306
+ (e): e is ToolResultSummaryEntry => e.type === 'toolResultSummary',
307
+ )!
308
+ expect(summary).toBeDefined()
309
+ expect(summary.summary).toMatch(/^read: f\.ts \(\d+KB\)$/)
310
+ expect(summary.totalLines).toBe(4)
311
+ expect(summary.headLines).toContain('line1')
312
+ expect(summary.fullEntry.message?.role).toBe('toolResult')
313
+
314
+ // includeToolResult 全文:返回原 Entry
315
+ const full = renderDetail([t], { includeToolResult: true })
316
+ expect(full).toHaveLength(3)
317
+ expect(
318
+ full.some(
319
+ (e): e is Entry => e.type !== 'toolResultSummary' && e.message?.role === 'toolResult',
320
+ ),
321
+ ).toBe(true)
322
+ })
323
+
324
+ it('v2 S1:空 content 的 toolResult totalLines=0(与 formatToolResultSummary 口径一致)', () => {
325
+ // 同一空 toolResult:summary 显示 "(0行)",totalLines 必须也是 0(非 ''.split('\n') 的 1)
326
+ const tcId = 'call_bash1'
327
+ const t = turn(
328
+ 0,
329
+ [
330
+ uEntry('U', 'q'),
331
+ aEntry('A', 'r', [{ name: 'bash', id: tcId, arguments: { command: 'ls' } }]),
332
+ tEntry('T', '', { toolCallId: tcId, toolName: 'bash' }), // 空 content
333
+ ],
334
+ { userEntry: uEntry('U', 'q') },
335
+ )
336
+ const def = renderDetail([t])
337
+ const summary = def.find(
338
+ (e): e is ToolResultSummaryEntry => e.type === 'toolResultSummary',
339
+ )!
340
+ expect(summary).toBeDefined()
341
+ expect(summary.totalLines).toBe(0) // 空结果 = 0 行(非 1)
342
+ expect(summary.headLines).toBe('') // 空结果无头行
343
+ // 对照:summary 文案也不含行数(formatToolResultSummary 空结果不 append (N行))
344
+ expect(summary.summary).not.toContain('行)')
345
+ })
346
+ })
347
+
348
+ // 测试内联的类型守卫镜像(验证 renderDetail 剥离效果,不依赖 render 内部导出)
349
+ function extractTextForTest(content: unknown): string {
350
+ if (typeof content === 'string') return content
351
+ if (Array.isArray(content)) {
352
+ return content
353
+ .filter((b) => (b as { type?: string }).type !== 'thinking')
354
+ .map((b) => ((b as { text?: string }).text) ?? '')
355
+ .join('')
356
+ }
357
+ return ''
358
+ }
359
+ function extractThinkingForTest(content: unknown): string {
360
+ if (Array.isArray(content)) {
361
+ return content
362
+ .filter((b) => (b as { type?: string }).type === 'thinking')
363
+ .map((b) => ((b as { thinking?: string }).thinking) ?? '')
364
+ .join('')
365
+ }
366
+ return ''
367
+ }