@zhushanwen/pi-session-reader 0.13.1 → 0.13.3

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.
@@ -1,121 +1,758 @@
1
+ // tree.test.ts —— session-reader C01 簇合并产物(round5-G)。
2
+ // 共同被测业务模块:`src/core/tree.ts`。mock 相容:全簇零 vi.mock。
3
+ // 顶层正文按来源包 describe 纯前缀分节,it 层名零改动。
4
+ //
5
+ // 来源:
6
+ // - src/__tests__/render.test.ts(20 用例,skip 0)
7
+ // - src/__tests__/tree.test.ts(10 用例,skip 0)
8
+ // - src/__tests__/turns.test.ts(9 用例,skip 0)
9
+
1
10
  import { describe, it, expect } from 'vitest'
2
- import { buildTreeView } from '../core/tree.js'
3
- import type { Entry } from '@zhushanwen/session-core'
4
-
5
- /** 构造最小 Entry(tree.ts 只消费 type/id/parentId,其余字段省略) */
6
- function e(id: string, parentId: string | null): Entry {
7
- return { type: 'message', id, parentId }
8
- }
9
-
10
- describe('buildTreeView', () => {
11
- it('线性链:leafPath 含全部 id,branches/orphans 空', () => {
12
- const entries = [e('A', null), e('B', 'A'), e('C', 'B')]
13
- const t = buildTreeView(entries)
14
-
15
- expect(t.leafPath).toEqual(['A', 'B', 'C'])
16
- expect(t.branches.size).toBe(0)
17
- expect(t.orphans).toEqual([])
18
- })
11
+ import { renderOutline, renderExpand, renderDetail, type ToolResultSummaryEntry } from '../core/render.js'
12
+ import { type Entry } from '@zhushanwen/session-core'
13
+ import { type Turn, segmentTurns } from '../core/turns.js'
14
+ import { type TreeView, buildTreeView } from '../core/tree.js'
19
15
 
20
- it('单分叉:旁支子树按 forkPoint 聚合(D+E 归 A,count=2)', () => {
21
- // 主链 A→B→C,旁支 A→D→E;C 放最后 → leafId=C
22
- const entries = [e('A', null), e('B', 'A'), e('D', 'A'), e('E', 'D'), e('C', 'B')]
23
- const t = buildTreeView(entries)
16
+ // ======== 来源:render.test.ts ========
17
+ describe("render", () => {
18
+ // ---- 构造助手 ----
24
19
 
25
- expect(t.leafPath).toEqual(['A', 'B', 'C'])
26
- expect(t.branches.size).toBe(1)
27
- expect(t.branches.get('A')).toBe(2)
28
- expect(t.orphans).toEqual([])
29
- })
20
+ function uEntry(id: string, text: string): Entry {
21
+ return {
22
+ type: 'message',
23
+ id,
24
+ parentId: null,
25
+ message: { role: 'user', content: [{ type: 'text', text }] },
26
+ }
27
+ }
30
28
 
31
- it('多级分叉:深层旁支沿祖先链归到 leafPath 上的 forkPoint', () => {
32
- // 主链 A→B→C→D,旁支 B→X→Y→Z;D 放最后 → leafId=D
33
- const entries = [
34
- e('A', null), e('B', 'A'),
35
- e('X', 'B'), e('Y', 'X'), e('Z', 'Y'),
36
- e('C', 'B'), e('D', 'C'),
37
- ]
38
- const t = buildTreeView(entries)
39
-
40
- expect(t.leafPath).toEqual(['A', 'B', 'C', 'D'])
41
- // X/Y/Z 全部沿祖先链回到 forkPoint=B → count=3
42
- expect(t.branches.get('B')).toBe(3)
43
- expect(t.orphans).toEqual([])
44
- })
29
+ function aEntry(
30
+ id: string,
31
+ text: string,
32
+ toolCalls?: Array<{ name: string; id?: string; arguments?: Record<string, unknown> }>,
33
+ thinking?: string,
34
+ ): Entry {
35
+ // v2:toolCalls 进 content 的 toolCall block(真实结构,probe 实测 519),不再设 message.toolCalls
36
+ const content: unknown[] = [{ type: 'text', text }]
37
+ if (thinking !== undefined) content.push({ type: 'thinking', thinking })
38
+ if (toolCalls !== undefined) {
39
+ for (const tc of toolCalls) {
40
+ content.push({
41
+ type: 'toolCall',
42
+ id: tc.id ?? `call_${tc.name}`,
43
+ name: tc.name,
44
+ arguments: tc.arguments ?? {},
45
+ })
46
+ }
47
+ }
48
+ return { type: 'message', id, parentId: null, message: { role: 'assistant', content } }
49
+ }
45
50
 
46
- it('多个 forkPoint:旁支挂在 leafPath 不同节点上各自计数', () => {
47
- // 主链 A→B→C→D,旁支挂 A(A→X)和挂 C(C→Y);D 放最后 → leafId=D
48
- const entries = [e('A', null), e('X', 'A'), e('B', 'A'), e('C', 'B'), e('Y', 'C'), e('D', 'C')]
49
- const t = buildTreeView(entries)
51
+ function tEntry(
52
+ id: string,
53
+ content: string,
54
+ opts?: { toolCallId?: string; toolName?: string },
55
+ ): Entry {
56
+ // v2:content 保持 string(测试简化,toolResultText 对 string/数组都处理);
57
+ // 加 toolCallId/toolName 供 O2 类型化摘要关联测试
58
+ const message: NonNullable<Entry['message']> = { role: 'toolResult', content }
59
+ if (opts?.toolName !== undefined) message.toolName = opts.toolName
60
+ if (opts?.toolCallId !== undefined) message.toolCallId = opts.toolCallId
61
+ return { type: 'message', id, parentId: null, message }
62
+ }
50
63
 
51
- expect(t.leafPath).toEqual(['A', 'B', 'C', 'D'])
52
- expect(t.branches.get('A')).toBe(1)
53
- expect(t.branches.get('C')).toBe(1)
54
- expect(t.orphans).toEqual([])
55
- })
64
+ function turn(
65
+ idx: number,
66
+ entries: Entry[],
67
+ opts: { userEntry?: Entry; isCompaction?: boolean; startTime?: string } = {},
68
+ ): Turn {
69
+ const t: Turn = {
70
+ index: idx,
71
+ entries,
72
+ userEntry: opts.userEntry,
73
+ isCompaction: opts.isCompaction ?? false,
74
+ }
75
+ if (opts.startTime !== undefined) t.startTime = opts.startTime
76
+ return t
77
+ }
56
78
 
57
- it('孤儿:parentId 指向不存在 → 进 orphans;leafPath 在断点停', () => {
58
- // A 是 leafPath 根但 parentId 断(MISSING_A 不在 index)→ leafPath 在 A 处停
59
- // X 非 leafPath 且 parentId 断 → 孤儿
60
- // B=leafId(最后),B.parentId=A
61
- const entries = [e('A', 'MISSING_A'), e('X', 'MISSING_X'), e('B', 'A')]
62
- const t = buildTreeView(entries)
63
-
64
- // 回溯 B→A→MISSING_A(不在 index) 停
65
- expect(t.leafPath).toEqual(['A', 'B'])
66
- // A 在 leafPath 上(断点根,有归属)→ 不重复计入 orphans
67
- expect(t.orphans).toEqual(['X'])
68
- expect(t.branches.size).toBe(0)
69
- })
79
+ function emptyTree(): TreeView {
80
+ return { leafPath: [], branches: new Map(), orphans: [] }
81
+ }
82
+
83
+ describe('renderOutline', () => {
84
+ it('1. 预算充足 → 全部字段完整渲染(userBrief 截断 / toolSummary 聚合 / omittedBytes 正确)', () => {
85
+ const t = turn(0, [
86
+ uEntry('U', 'do something'),
87
+ aEntry('A', 'sure', [{ name: 'bash' }, { name: 'bash' }, { name: 'read' }, { name: 'read' }], 'secret'),
88
+ tEntry('T', 'output'),
89
+ ], { userEntry: uEntry('U', 'do something') })
90
+
91
+ const result = renderOutline([t], emptyTree(), { budget: 2000 })
92
+ const b = result.turns[0]
93
+
94
+ expect(b.userBrief).toBe('do something') // 12 chars,未截断
95
+ expect(b.toolSummary).toBe('bash×2,read×2')
96
+ expect(b.assistantBrief).toBe('sure')
97
+ // thinking 'secret'(6B) + toolResult 'output'(6B)
98
+ expect(b.omittedBytes).toBe(12)
99
+ expect(result.truncated).toBeUndefined()
100
+ // E7 行渲染统一:lines 随结果返回,与 turns 一一对应(预算度量 = 展示行同一份)
101
+ expect(result.lines).toEqual(['T000 · do something · bash×2,read×2 · → sure · [12B omitted]'])
102
+ })
103
+
104
+ it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
105
+ // 预算紧张降级后 L1 行不含 assistantBrief(预算充足 level 0 行含);budget=40, 3 turns → perTurnCharBudget≈53
106
+ // 行 = head + userBrief(10) + toolSummary(20 个工具名≈63 chars) → ~80 > 53 → 砍 toolSummary
107
+ const toolCalls = Array.from({ length: 20 }, (_, k) => ({ name: `t${String(k).padStart(2, '0')}` }))
108
+ const turns = [0, 1, 2].map((i) =>
109
+ turn(i, [uEntry(`U${i}`, 'u'.repeat(10)), aEntry(`A${i}`, 'a'.repeat(10), toolCalls)], {
110
+ userEntry: uEntry(`U${i}`, 'u'.repeat(10)),
111
+ }),
112
+ )
113
+ const result = renderOutline(turns, emptyTree(), { budget: 40 })
114
+
115
+ expect(result.turns).toHaveLength(3)
116
+ for (const b of result.turns) {
117
+ expect(b.toolSummary).toBe('') // 被砍(降级)
118
+ expect(b.userBrief).toBe('u'.repeat(10)) // 骨架保留
119
+ }
120
+ // E7:降级后的渲染行(骨架形态)与 briefs 字段同步——行内无 → 结论段、无工具聚合段
121
+ expect(result.lines).toHaveLength(3)
122
+ expect(result.lines).toEqual(['T000 · uuuuuuuuuu', 'T001 · uuuuuuuuuu', 'T002 · uuuuuuuuuu'])
123
+ expect(result.truncated).toBeUndefined()
124
+ })
125
+
126
+ it('2b. 降级(砍 assistantBrief 档):行内 → 结论段消失、toolSummary 保留,与改前语义一致', () => {
127
+ // budget=20 → perTurnCharBudget=80;level 0 行 = T000 · u*10 · bash×2 · → a*80… ≈112 > 80
128
+ // → 砍 assistantBrief 后 ≈26 ≤ 80 停(S3:该档形态与改前 handler 侧渲染降级后 briefs 等价)
129
+ const t = turn(
130
+ 0,
131
+ [uEntry('U', 'u'.repeat(10)), aEntry('A', 'a'.repeat(100), [{ name: 'bash' }, { name: 'bash' }])],
132
+ { userEntry: uEntry('U', 'u'.repeat(10)) },
133
+ )
134
+ const result = renderOutline([t], emptyTree(), { budget: 20 })
135
+
136
+ expect(result.turns[0].assistantBrief).toBe('') // 被砍
137
+ expect(result.turns[0].toolSummary).toBe('bash×2') // 保留
138
+ expect(result.lines).toEqual(['T000 · uuuuuuuuuu · bash×2'])
139
+ expect(result.truncated).toBeUndefined()
140
+ })
141
+
142
+ it('3. 总超预算 → truncated 计数 > 0(从尾部丢弃)', () => {
143
+ // budget=10, 5 turns → 每行降级到骨架 17 chars;total 85/4=21>10 → 截断保留 2,truncated=3
144
+ const turns = [0, 1, 2, 3, 4].map((i) =>
145
+ turn(i, [uEntry(`U${i}`, 'u'.repeat(10)), aEntry(`A${i}`, 'a'.repeat(10))], {
146
+ userEntry: uEntry(`U${i}`, 'u'.repeat(10)),
147
+ }),
148
+ )
149
+ const result = renderOutline(turns, emptyTree(), { budget: 10 })
150
+
151
+ expect(result.truncated).toBe(3)
152
+ expect(result.turns).toHaveLength(2)
153
+ // E7:lines 与 turns 同步截短(truncateToTotalBudget 双数组一致)
154
+ expect(result.lines).toHaveLength(2)
155
+ })
156
+
157
+ it('4. toolSummary 聚合:同名计数 ×N,不同名逗号分隔', () => {
158
+ const t = turn(
159
+ 0,
160
+ [uEntry('U', 'q'), aEntry('A', 'r', [{ name: 'bash' }, { name: 'bash' }, { name: 'read' }, { name: 'read' }, { name: 'edit' }])],
161
+ { userEntry: uEntry('U', 'q') },
162
+ )
163
+ const result = renderOutline([t], emptyTree(), { budget: 2000 })
164
+ expect(result.turns[0].toolSummary).toBe('bash×2,read×2,edit')
165
+ })
166
+
167
+ it('5. omittedBytes = toolResult content + thinking 字节和', () => {
168
+ const t = turn(
169
+ 0,
170
+ [uEntry('U', 'q'), aEntry('A', 'r', undefined, 'th'), tEntry('T', 'out')],
171
+ { userEntry: uEntry('U', 'q') },
172
+ )
173
+ const result = renderOutline([t], emptyTree(), { budget: 2000 })
174
+ // thinking 'th'(2B) + toolResult 'out'(3B) = 5
175
+ expect(result.turns[0].omittedBytes).toBe(5)
176
+ })
177
+
178
+ it('6. granularity:entry → 不聚合,每 entry 一行', () => {
179
+ const turns = [
180
+ turn(0, [uEntry('U0', 'a'), aEntry('A0', 'b')], { userEntry: uEntry('U0', 'a') }),
181
+ turn(1, [uEntry('U1', 'c')], { userEntry: uEntry('U1', 'c') }),
182
+ ]
183
+ const result = renderOutline(turns, emptyTree(), { granularity: 'entry', budget: 2000 })
184
+ // 3 leaf entries → 3 行
185
+ expect(result.turns).toHaveLength(3)
186
+ expect(result.turns.map((b) => b.index)).toEqual([0, 1, 2])
187
+ })
188
+
189
+ it('7. allBranches:true → forkPoint 处标注 branch', () => {
190
+ const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
191
+ const userEntry = uEntry('U', 'hi')
192
+ const turns = [turn(0, [userEntry], { userEntry })]
193
+
194
+ const withBranches = renderOutline(turns, tree, { allBranches: true })
195
+ expect(withBranches.turns[0].branch).toBe('U')
70
196
 
71
- it('leafId = entries 最后一条的 id(D-2 语义):同样节点不同顺序 → 不同 leafPath', () => {
72
- const ordered = [e('A', null), e('B', 'A'), e('C', 'B'), e('D', 'C')] // leafId=D
73
- const swapped = [e('A', null), e('B', 'A'), e('D', 'C'), e('C', 'B')] // leafId=C,D 变旁支
197
+ const withoutBranches = renderOutline(turns, tree, { allBranches: false })
198
+ expect(withoutBranches.turns[0].branch).toBeUndefined()
199
+ })
74
200
 
75
- const t1 = buildTreeView(ordered)
76
- const t2 = buildTreeView(swapped)
201
+ it('7b. E7 旁支标记统一:渲染行 `[旁支 N entries]`(N = 该 forkPoint 下旁支子树 entry 数)', () => {
202
+ // branches: U → 3 entries 旁支子树(E7/D3:outline 与 export 两条路径共用此形态)
203
+ const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
204
+ const userEntry = uEntry('U', 'hi')
205
+ const turns = [turn(0, [userEntry], { userEntry })]
77
206
 
78
- expect(t1.leafPath).toEqual(['A', 'B', 'C', 'D'])
79
- expect(t2.leafPath).toEqual(['A', 'B', 'C'])
80
- // swapped 里 D 挂在 C(leafPath 末端)下,成旁支
81
- expect(t2.branches.get('C')).toBe(1)
207
+ const result = renderOutline(turns, tree, { allBranches: true })
208
+ expect(result.lines).toEqual(['T000 · hi · [旁支 3 entries]'])
209
+ // branchSize 缺失(allBranches:false)不标旁支
210
+ const noBranch = renderOutline(turns, tree, { allBranches: false })
211
+ expect(noBranch.lines).toEqual(['T000 · hi'])
212
+ })
213
+
214
+ it('8. 多 turn outline:tokenEstimate <= 1500 + assistantBrief/toolSummary 非空 + stats 完整(v2 O1)', () => {
215
+ // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 行含结论与工具聚合,
216
+ // 阈值 1500(design §3.3 D4)。3 turn 合成数据,每 turn 带 toolCall + toolResult
217
+ const turns = [0, 1, 2].map((i) =>
218
+ turn(
219
+ i,
220
+ [
221
+ uEntry(`U${i}`, `问题 ${i}`),
222
+ aEntry(`A${i}`, `结论 ${i}`, [{ name: 'bash' }, { name: 'read' }]),
223
+ tEntry(`T${i}`, 'output'),
224
+ ],
225
+ { userEntry: uEntry(`U${i}`, `问题 ${i}`) },
226
+ ),
227
+ )
228
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
229
+
230
+ expect(result.turns.length).toBe(3)
231
+ expect(result.tokenEstimate).toBeLessThanOrEqual(1500)
232
+ // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示工具(修 v1 恒空 bug)
233
+ expect(result.turns.some((b) => b.assistantBrief !== '')).toBe(true)
234
+ expect(result.turns.some((b) => b.toolSummary !== '')).toBe(true)
235
+ // stats 完整性
236
+ expect(result.stats.totalTurns).toBe(3)
237
+ // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过)
238
+ expect(result.stats.totalEntries).toBeGreaterThan(0)
239
+ })
240
+
241
+ // ---- D9② 占位行折叠:纯记录条目 turn 不产 outline 行、不占轮次 ----
242
+
243
+ function recEntry(id: string, type: string): Entry {
244
+ // pi 1.0 纯记录条目形态(usage/session_info/model_change 等,无 message 字段)
245
+ return { type, id, parentId: null }
246
+ }
247
+
248
+ function sysEntry(id: string): Entry {
249
+ // pi 1.0 system 消息(role=system,工具集变更高频追加)——非对话轴,随折叠
250
+ return { type: 'message', id, parentId: null, message: { role: 'system', content: 'sys' } }
251
+ }
252
+
253
+ it('9. D9② 纯记录条目 turn 折叠:不产行、totalTurns 只数对话轮,对话 turn 的 T 序号保留原 index', () => {
254
+ const turns = [
255
+ turn(0, [recEntry('MC', 'model_change'), recEntry('TL', 'thinking_level_change')]),
256
+ turn(1, [uEntry('U1', 'first question')], { userEntry: uEntry('U1', 'first question') }),
257
+ turn(2, [recEntry('US', 'usage')]),
258
+ turn(3, [uEntry('U2', 'second question')], { userEntry: uEntry('U2', 'second question') }),
259
+ ]
260
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
261
+
262
+ // 记录 turn(0/2)折叠:行只剩两个对话 turn
263
+ expect(result.turns).toHaveLength(2)
264
+ expect(result.lines).toHaveLength(2)
265
+ expect(result.stats.totalTurns).toBe(2)
266
+ // T 序号 = 原 turn.index(expand/detail 的入口地址稳定,折叠不重编号)
267
+ expect(result.turns.map((b) => b.index)).toEqual([1, 3])
268
+ // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据):2+1+1+1 = 5
269
+ expect(result.stats.totalEntries).toBe(5)
270
+ })
271
+
272
+ it('10. D9② system 消息与 custom 审计条目同折叠;custom_message 参与对话流保留', () => {
273
+ const turns = [
274
+ turn(0, [sysEntry('S1'), recEntry('C1', 'custom')]),
275
+ turn(1, [uEntry('U1', 'q')], { userEntry: uEntry('U1', 'q') }),
276
+ ]
277
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
278
+ expect(result.turns).toHaveLength(1)
279
+ expect(result.stats.totalTurns).toBe(1)
280
+
281
+ const cm: Entry = { type: 'custom_message', id: 'CM', parentId: null, customType: 'plan-context', content: 'prompt' }
282
+ const withCm = renderOutline([turn(0, [cm]), turn(1, [uEntry('U1', 'q')], { userEntry: uEntry('U1', 'q') })], emptyTree(), { budget: 2000 })
283
+ expect(withCm.turns).toHaveLength(2)
284
+ })
285
+
286
+ it('11. D9② entry 粒度同谓词:纯记录条目不产行,entryIdx 只数对话条目', () => {
287
+ const turns = [
288
+ turn(0, [recEntry('MC', 'model_change'), uEntry('U0', 'a')], { userEntry: uEntry('U0', 'a') }),
289
+ turn(1, [recEntry('US', 'usage')]),
290
+ ]
291
+ const result = renderOutline(turns, emptyTree(), { granularity: 'entry', budget: 2000 })
292
+ expect(result.turns).toHaveLength(1)
293
+ expect(result.turns[0]?.userBrief).toBe('a')
294
+ })
295
+
296
+ it('12. D9② branch_summary 归纯记录折叠:前置 turn 成 turn 不产行(pi 1.0 纯记录类)', () => {
297
+ // pi 1.0 dist branchWithSummary 落盘形态:{type:'branch_summary', id, parentId, fromId,
298
+ // summary, details, usage, fromHook},无 message 字段。归类 = 纯记录(分叉操作痕迹,
299
+ // 非当前路径对话内容;taiji 无消息编辑入口不产生该形态)
300
+ const bs: Entry = { type: 'branch_summary', id: 'BS', parentId: null, summary: 'original path digest' }
301
+ const turns = [
302
+ turn(0, [bs]),
303
+ turn(1, [uEntry('U1', 'new branch question')], { userEntry: uEntry('U1', 'new branch question') }),
304
+ ]
305
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
306
+ // 折叠:前置 turn 不产行,outline 只剩新路径对话 turn
307
+ expect(result.turns).toHaveLength(1)
308
+ expect(result.lines).toHaveLength(1)
309
+ expect(result.stats.totalTurns).toBe(1)
310
+ // T 序号保留原 turn.index(expand/detail 入口地址稳定)
311
+ expect(result.turns.map((b) => b.index)).toEqual([1])
312
+ // 字节/条目统计保持全量口径:BS + U1 = 2
313
+ expect(result.stats.totalEntries).toBe(2)
314
+ })
82
315
  })
83
316
 
84
- it('空 entries:返回空视图', () => {
85
- const t = buildTreeView([])
86
- expect(t.leafPath).toEqual([])
87
- expect(t.branches.size).toBe(0)
88
- expect(t.orphans).toEqual([])
317
+ describe('renderExpand', () => {
318
+ it('单轮展开:header 含 entry 数 + 每个 entry brief', () => {
319
+ const t = turn(
320
+ 0,
321
+ [uEntry('U', 'hello world'), aEntry('A', 'ok'), tEntry('T', 'result text')],
322
+ { userEntry: uEntry('U', 'hello world'), startTime: '2026-05-28T03:17:12.844Z' },
323
+ )
324
+ const out = renderExpand(t)
325
+
326
+ expect(out.turn).toContain('3 entries')
327
+ expect(out.turn).toContain('03:17')
328
+ expect(out.entries).toHaveLength(3)
329
+ expect(out.entries[0]).toMatchObject({ index: 0, type: 'message', role: 'user' })
330
+ expect(out.entries[0].brief).toBe('hello world')
331
+ expect(out.entries[1].role).toBe('assistant')
332
+ expect(out.entries[2].role).toBe('toolResult')
333
+ // toolResult 的 omittedBytes = content 字节
334
+ expect(out.entries[2].omittedBytes).toBe(Buffer.byteLength('result text', 'utf8'))
335
+ })
336
+
337
+ it('v2 O2:toolResult 类型化摘要(bash: <cmd> (N行),toolCallId 关联取 args)', () => {
338
+ // 构造带 toolCallId 关联的 turn:assistant 调 bash,toolResult 靠 toolCallId 关联回去取 command
339
+ const tcId = 'call_bash1'
340
+ const t = turn(
341
+ 0,
342
+ [
343
+ uEntry('U', '查一下文件'),
344
+ aEntry('A', '好的', [{ name: 'bash', id: tcId, arguments: { command: 'ls -la src/' } }]),
345
+ tEntry('T', 'file1\nfile2\nfile3', { toolCallId: tcId, toolName: 'bash' }),
346
+ ],
347
+ { userEntry: uEntry('U', '查一下文件') },
348
+ )
349
+ const out = renderExpand(t)
350
+ const trBrief = out.entries.find((e) => e.role === 'toolResult')!
351
+ // 类型化摘要:bash: <cmd> (N行),不是结果文本前 100 字(file1/file2...)
352
+ expect(trBrief.brief).toContain('bash: ls -la src/')
353
+ expect(trBrief.brief).toContain('3行')
354
+ expect(trBrief.brief).not.toContain('file1')
355
+ })
356
+
357
+ it('v2 O2:read/edit/write 等工具的类型化摘要', () => {
358
+ const t = turn(
359
+ 0,
360
+ [
361
+ uEntry('U', '改代码'),
362
+ aEntry('A1', '读文件', [{ name: 'read', id: 'r1', arguments: { path: '/a/b/src/index.ts' } }]),
363
+ tEntry('TR1', 'content', { toolCallId: 'r1', toolName: 'read' }),
364
+ aEntry('A2', '编辑', [{ name: 'edit', id: 'e1', arguments: { path: '/a/b/foo.ts', edits: [{}, {}] } }]),
365
+ tEntry('TR2', 'ok', { toolCallId: 'e1', toolName: 'edit' }),
366
+ aEntry('A3', '写', [{ name: 'write', id: 'w1', arguments: { path: '/a/b/out.txt', content: 'x'.repeat(2048) } }]),
367
+ tEntry('TR3', 'ok', { toolCallId: 'w1', toolName: 'write' }),
368
+ ],
369
+ { userEntry: uEntry('U', '改代码') },
370
+ )
371
+ const out = renderExpand(t)
372
+ const briefs = out.entries.filter((e) => e.role === 'toolResult').map((e) => e.brief)
373
+ // read: read: index.ts (NKB)(结果 KB)
374
+ expect(briefs[0]).toMatch(/^read: index\.ts \(\d+KB\)$/) // 点号转义
375
+ // edit: edit: foo.ts (2 blocks)(参数 blocks)
376
+ expect(briefs[1]).toBe('edit: foo.ts (2 blocks)')
377
+ // write: write: out.txt (NKB)(参数 content KB)
378
+ expect(briefs[2]).toMatch(/^write: out\.txt \(\d+KB\)$/) // 修正:out.txt
379
+ })
89
380
  })
90
381
 
91
- it('多 root 独立子树:触不到 leafPath 的 entry 不计入 branches/orphans', () => {
92
- // 主链 R→A(leafId=A),另有独立 root X→Y(X.parentId=null,与主链无连接)
93
- const entries = [e('R', null), e('X', null), e('Y', 'X'), e('A', 'R')]
94
- const t = buildTreeView(entries)
382
+ describe('renderDetail', () => {
383
+ it('默认 toolResult 摘要态(O3)+ thinking 剥离;includeToolResult/includeThinking 取回', () => {
384
+ const t = turn(0, [
385
+ uEntry('U', 'q'),
386
+ aEntry('A', 'visible', undefined, 'hidden-thinking'),
387
+ tEntry('T', 'tool-output'),
388
+ ], { userEntry: uEntry('U', 'q') })
389
+
390
+ // v2 O3:默认不再 continue 跳过 toolResult,条目数不减少(toolResult 变摘要态)
391
+ const defaultOut = renderDetail([t])
392
+ expect(defaultOut).toHaveLength(3)
393
+ expect(defaultOut.some((e) => e.type === 'toolResultSummary')).toBe(true)
394
+ // thinking 块被剥离(assistant 副本,无 thinking)
395
+ const entries = defaultOut.filter((e): e is Entry => e.type !== 'toolResultSummary')
396
+ const assistantDefault = entries.find((e) => e.message?.role === 'assistant')!
397
+ expect(extractTextForTest(assistantDefault.message!.content)).toBe('visible')
398
+
399
+ // includeToolResult:toolResult 原文 entry 回来(非摘要态)
400
+ const withTool = renderDetail([t], { includeToolResult: true })
401
+ expect(withTool).toHaveLength(3)
402
+ expect(
403
+ withTool.some(
404
+ (e): e is Entry => e.type !== 'toolResultSummary' && e.message?.role === 'toolResult',
405
+ ),
406
+ ).toBe(true)
407
+
408
+ // includeThinking:thinking 块回来
409
+ const withThinking = renderDetail([t], { includeThinking: true })
410
+ const entriesT = withThinking.filter((e): e is Entry => e.type !== 'toolResultSummary')
411
+ const assistantThinking = entriesT.find((e) => e.message?.role === 'assistant')!
412
+ expect(extractTextForTest(assistantThinking.message!.content)).toBe('visible')
413
+ expect(extractThinkingForTest(assistantThinking.message!.content)).toBe('hidden-thinking')
414
+ })
415
+
416
+ it('v2 O3:renderDetail 默认摘要态 + includeToolResult 全文', () => {
417
+ const tcId = 'call_r1'
418
+ const t = turn(
419
+ 0,
420
+ [
421
+ uEntry('U', 'q'),
422
+ aEntry('A', 'r', [{ name: 'read', id: tcId, arguments: { path: '/x/y/f.ts' } }]),
423
+ tEntry('T', 'line1\nline2\nline3\nline4', { toolCallId: tcId, toolName: 'read' }),
424
+ ],
425
+ { userEntry: uEntry('U', 'q') },
426
+ )
427
+
428
+ // 默认摘要态
429
+ const def = renderDetail([t])
430
+ expect(def).toHaveLength(3)
431
+ const summary = def.find(
432
+ (e): e is ToolResultSummaryEntry => e.type === 'toolResultSummary',
433
+ )!
434
+ expect(summary).toBeDefined()
435
+ expect(summary.summary).toMatch(/^read: f\.ts \(\d+KB\)$/)
436
+ expect(summary.totalLines).toBe(4)
437
+ expect(summary.headLines).toContain('line1')
95
438
 
96
- expect(t.leafPath).toEqual(['R', 'A'])
97
- // X 是独立 root(parentId=null)→ 跳过;Y 触不到 leafPath → 静默忽略
98
- expect(t.branches.size).toBe(0)
99
- expect(t.orphans).toEqual([])
439
+ // includeToolResult 全文:返回原 Entry
440
+ const full = renderDetail([t], { includeToolResult: true })
441
+ expect(full).toHaveLength(3)
442
+ expect(
443
+ full.some(
444
+ (e): e is Entry => e.type !== 'toolResultSummary' && e.message?.role === 'toolResult',
445
+ ),
446
+ ).toBe(true)
447
+ })
448
+
449
+ it('v2 S1:空 content 的 toolResult totalLines=0(与 formatToolResultSummary 口径一致)', () => {
450
+ // 同一空 toolResult:summary 显示 "(0行)",totalLines 必须也是 0(非 ''.split('\n') 的 1)
451
+ const tcId = 'call_bash1'
452
+ const t = turn(
453
+ 0,
454
+ [
455
+ uEntry('U', 'q'),
456
+ aEntry('A', 'r', [{ name: 'bash', id: tcId, arguments: { command: 'ls' } }]),
457
+ tEntry('T', '', { toolCallId: tcId, toolName: 'bash' }), // 空 content
458
+ ],
459
+ { userEntry: uEntry('U', 'q') },
460
+ )
461
+ const def = renderDetail([t])
462
+ const summary = def.find(
463
+ (e): e is ToolResultSummaryEntry => e.type === 'toolResultSummary',
464
+ )!
465
+ expect(summary).toBeDefined()
466
+ expect(summary.totalLines).toBe(0) // 空结果 = 0 行(非 1)
467
+ expect(summary.headLines).toBe('') // 空结果无头行
468
+ // 对照:summary 文案也不含行数(formatToolResultSummary 空结果不 append (N行))
469
+ expect(summary.summary).not.toContain('行)')
470
+ })
100
471
  })
101
472
 
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([])
473
+ // 测试内联的类型检查镜像(验证 renderDetail 剥离效果,不依赖 render 内部导出)
474
+ function extractTextForTest(content: unknown): string {
475
+ if (typeof content === 'string') return content
476
+ if (Array.isArray(content)) {
477
+ return content
478
+ .filter((b) => (b as { type?: string }).type !== 'thinking')
479
+ .map((b) => ((b as { text?: string }).text) ?? '')
480
+ .join('')
481
+ }
482
+ return ''
483
+ }
484
+
485
+ function extractThinkingForTest(content: unknown): string {
486
+ if (Array.isArray(content)) {
487
+ return content
488
+ .filter((b) => (b as { type?: string }).type === 'thinking')
489
+ .map((b) => ((b as { thinking?: string }).thinking) ?? '')
490
+ .join('')
491
+ }
492
+ return ''
493
+ }
494
+ });
495
+
496
+ // ======== 来源:tree.test.ts ========
497
+ describe("tree", () => {
498
+ /** 构造最小 Entry(tree.ts 只消费 type/id/parentId,其余字段省略) */
499
+ function e(id: string, parentId: string | null): Entry {
500
+ return { type: 'message', id, parentId }
501
+ }
502
+
503
+ describe('buildTreeView', () => {
504
+ it('线性链:leafPath 含全部 id,branches/orphans 空', () => {
505
+ const entries = [e('A', null), e('B', 'A'), e('C', 'B')]
506
+ const t = buildTreeView(entries)
507
+
508
+ expect(t.leafPath).toEqual(['A', 'B', 'C'])
509
+ expect(t.branches.size).toBe(0)
510
+ expect(t.orphans).toEqual([])
511
+ })
512
+
513
+ it('单分叉:旁支子树按 forkPoint 聚合(D+E 归 A,count=2)', () => {
514
+ // 主链 A→B→C,旁支 A→D→E;C 放最后 → leafId=C
515
+ const entries = [e('A', null), e('B', 'A'), e('D', 'A'), e('E', 'D'), e('C', 'B')]
516
+ const t = buildTreeView(entries)
517
+
518
+ expect(t.leafPath).toEqual(['A', 'B', 'C'])
519
+ expect(t.branches.size).toBe(1)
520
+ expect(t.branches.get('A')).toBe(2)
521
+ expect(t.orphans).toEqual([])
522
+ })
523
+
524
+ it('多级分叉:深层旁支沿祖先链归到 leafPath 上的 forkPoint', () => {
525
+ // 主链 A→B→C→D,旁支 B→X→Y→Z;D 放最后 → leafId=D
526
+ const entries = [
527
+ e('A', null), e('B', 'A'),
528
+ e('X', 'B'), e('Y', 'X'), e('Z', 'Y'),
529
+ e('C', 'B'), e('D', 'C'),
530
+ ]
531
+ const t = buildTreeView(entries)
532
+
533
+ expect(t.leafPath).toEqual(['A', 'B', 'C', 'D'])
534
+ // X/Y/Z 全部沿祖先链回到 forkPoint=B → count=3
535
+ expect(t.branches.get('B')).toBe(3)
536
+ expect(t.orphans).toEqual([])
537
+ })
538
+
539
+ it('多个 forkPoint:旁支挂在 leafPath 不同节点上各自计数', () => {
540
+ // 主链 A→B→C→D,旁支挂 A(A→X)和挂 C(C→Y);D 放最后 → leafId=D
541
+ const entries = [e('A', null), e('X', 'A'), e('B', 'A'), e('C', 'B'), e('Y', 'C'), e('D', 'C')]
542
+ const t = buildTreeView(entries)
543
+
544
+ expect(t.leafPath).toEqual(['A', 'B', 'C', 'D'])
545
+ expect(t.branches.get('A')).toBe(1)
546
+ expect(t.branches.get('C')).toBe(1)
547
+ expect(t.orphans).toEqual([])
548
+ })
549
+
550
+ it('孤儿:parentId 指向不存在 → 进 orphans;leafPath 在断点停', () => {
551
+ // A 是 leafPath 根但 parentId 断(MISSING_A 不在 index)→ leafPath 在 A 处停
552
+ // X 非 leafPath 且 parentId 断 → 孤儿
553
+ // B=leafId(最后),B.parentId=A
554
+ const entries = [e('A', 'MISSING_A'), e('X', 'MISSING_X'), e('B', 'A')]
555
+ const t = buildTreeView(entries)
556
+
557
+ // 回溯 B→A→MISSING_A(不在 index) 停
558
+ expect(t.leafPath).toEqual(['A', 'B'])
559
+ // A 在 leafPath 上(断点根,有归属)→ 不重复计入 orphans
560
+ expect(t.orphans).toEqual(['X'])
561
+ expect(t.branches.size).toBe(0)
562
+ })
563
+
564
+ it('leafId = entries 最后一条的 id(D-2 语义):同样节点不同顺序 → 不同 leafPath', () => {
565
+ const ordered = [e('A', null), e('B', 'A'), e('C', 'B'), e('D', 'C')] // leafId=D
566
+ const swapped = [e('A', null), e('B', 'A'), e('D', 'C'), e('C', 'B')] // leafId=C,D 变旁支
567
+
568
+ const t1 = buildTreeView(ordered)
569
+ const t2 = buildTreeView(swapped)
570
+
571
+ expect(t1.leafPath).toEqual(['A', 'B', 'C', 'D'])
572
+ expect(t2.leafPath).toEqual(['A', 'B', 'C'])
573
+ // swapped 里 D 挂在 C(leafPath 末端)下,成旁支
574
+ expect(t2.branches.get('C')).toBe(1)
575
+ })
576
+
577
+ it('空 entries:返回空视图', () => {
578
+ const t = buildTreeView([])
579
+ expect(t.leafPath).toEqual([])
580
+ expect(t.branches.size).toBe(0)
581
+ expect(t.orphans).toEqual([])
582
+ })
583
+
584
+ it('多 root 独立子树:触不到 leafPath 的 entry 不计入 branches/orphans', () => {
585
+ // 主链 R→A(leafId=A),另有独立 root X→Y(X.parentId=null,与主链无连接)
586
+ const entries = [e('R', null), e('X', null), e('Y', 'X'), e('A', 'R')]
587
+ const t = buildTreeView(entries)
588
+
589
+ expect(t.leafPath).toEqual(['R', 'A'])
590
+ // X 是独立 root(parentId=null)→ 跳过;Y 触不到 leafPath → 静默忽略
591
+ expect(t.branches.size).toBe(0)
592
+ expect(t.orphans).toEqual([])
593
+ })
594
+
595
+ it('subagent-identity 尾行(parentId=null)不劫持 leafId:跳过取末尾对话节点', () => {
596
+ // 模拟 subagent session:header A → message B → message C(对话链),尾行 identity sa-x(parentId=null)
597
+ const entries = [e('A', null), e('B', 'A'), e('C', 'B'), e('sa-x', null)]
598
+ const t = buildTreeView(entries)
599
+ // leafId 跳过 sa-x(parentId=null),取 C(末尾 parentId!==null)→ leafPath 含完整对话链
600
+ expect(t.leafPath).toEqual(['A', 'B', 'C'])
601
+ // sa-x parentId=null → 独立 root,不计旁支不计孤儿
602
+ expect(t.branches.size).toBe(0)
603
+ expect(t.orphans).toEqual([])
604
+ })
605
+
606
+ it('全空 session(仅 header + 元数据 custom,无 parentId!==null 对话节点)→ leafId fallback entries[0]', () => {
607
+ const entries = [e('A', null), e('sa-x', null)]
608
+ const t = buildTreeView(entries)
609
+ // 全 parentId=null → leafId fallback entries[0]=A → leafPath=[A]
610
+ expect(t.leafPath).toEqual(['A'])
611
+ expect(t.branches.size).toBe(0)
612
+ expect(t.orphans).toEqual([])
613
+ })
111
614
  })
615
+ });
616
+
617
+ // ======== 来源:turns.test.ts ========
618
+ describe("turns", () => {
619
+ // ---- Entry 构造助手(turns.ts 只消费 type/id/parentId/message.role) ----
620
+
621
+ function msg(
622
+ id: string,
623
+ parentId: string | null,
624
+ role: 'user' | 'assistant' | 'toolResult',
625
+ ): Entry {
626
+ return { type: 'message', id, parentId, message: { role, content: '' } }
627
+ }
628
+
629
+ function entry(id: string, parentId: string | null, type: string): Entry {
630
+ return { type, id, parentId }
631
+ }
632
+
633
+ describe('segmentTurns', () => {
634
+ it('1. user 开 turn,后续 assistant/toolResult 并入同一 turn', () => {
635
+ const entries = [msg('U', null, 'user'), msg('A', 'U', 'assistant'), msg('T', 'A', 'toolResult')]
636
+ const turns = segmentTurns(entries, new Set(['U', 'A', 'T']))
637
+
638
+ expect(turns).toHaveLength(1)
639
+ expect(turns[0].index).toBe(0)
640
+ expect(turns[0].isCompaction).toBe(false)
641
+ expect(turns[0].userEntry?.id).toBe('U')
642
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A', 'T'])
643
+ })
644
+
645
+ it('2. compaction 开新 turn(isCompaction=true,无 userEntry),后续 user 又开一个 turn', () => {
646
+ const entries = [
647
+ msg('U', null, 'user'),
648
+ msg('A', 'U', 'assistant'),
649
+ entry('C', 'A', 'compaction'),
650
+ msg('U2', 'C', 'user'),
651
+ ]
652
+ const turns = segmentTurns(entries, new Set(['U', 'A', 'C', 'U2']))
653
+
654
+ expect(turns).toHaveLength(3)
655
+ // turn 0:user + assistant
656
+ expect(turns[0].userEntry?.id).toBe('U')
657
+ expect(turns[0].isCompaction).toBe(false)
658
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A'])
659
+ // turn 1:compaction 独立成 turn
660
+ expect(turns[1].isCompaction).toBe(true)
661
+ expect(turns[1].userEntry).toBeUndefined()
662
+ expect(turns[1].entries.map((e) => e.id)).toEqual(['C'])
663
+ // turn 2:新 user
664
+ expect(turns[2].userEntry?.id).toBe('U2')
665
+ expect(turns[2].isCompaction).toBe(false)
666
+ })
667
+
668
+ it('3. model_change / thinking_level_change / custom 并入当前 turn', () => {
669
+ const entries = [
670
+ msg('U', null, 'user'),
671
+ entry('M', 'U', 'model_change'),
672
+ entry('T', 'M', 'thinking_level_change'),
673
+ entry('X', 'T', 'custom'),
674
+ ]
675
+ const turns = segmentTurns(entries, new Set(['U', 'M', 'T', 'X']))
676
+
677
+ expect(turns).toHaveLength(1)
678
+ expect(turns[0].userEntry?.id).toBe('U')
679
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'M', 'T', 'X'])
680
+ })
681
+
682
+ it('4. branch entry(不在 leafSet)不计入', () => {
683
+ const entries = [
684
+ msg('U', null, 'user'),
685
+ msg('A', 'U', 'assistant'),
686
+ msg('B1', 'U', 'assistant'), // 旁支:不在 leafSet
687
+ msg('B2', 'B1', 'toolResult'), // 旁支子节点
688
+ ]
689
+ const turns = segmentTurns(entries, new Set(['U', 'A']))
690
+
691
+ expect(turns).toHaveLength(1)
692
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A'])
693
+ })
694
+
695
+ it('5. 孤儿 assistant(首个 entry 是 assistant 无前置 user)→ 并入 turn 0(preface)', () => {
696
+ const entries = [msg('O', null, 'assistant'), msg('U', 'O', 'user')]
697
+ const turns = segmentTurns(entries, new Set(['O', 'U']))
698
+
699
+ expect(turns).toHaveLength(2)
700
+ // turn 0:preface(孤儿 assistant),无 userEntry,非 compaction
701
+ expect(turns[0].isCompaction).toBe(false)
702
+ expect(turns[0].userEntry).toBeUndefined()
703
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['O'])
704
+ // turn 1:首个 user
705
+ expect(turns[1].userEntry?.id).toBe('U')
706
+ expect(turns[1].index).toBe(1)
707
+ })
708
+
709
+ it('6. session header 被忽略(即使在 leafSet 中也不计 turn)', () => {
710
+ const entries = [
711
+ entry('S', null, 'session'),
712
+ msg('U', 'S', 'user'),
713
+ msg('A', 'U', 'assistant'),
714
+ ]
715
+ // S 也在 leafSet,验证规则 1 优先于规则 5
716
+ const turns = segmentTurns(entries, new Set(['S', 'U', 'A']))
717
+
718
+ expect(turns).toHaveLength(1)
719
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A'])
720
+ })
721
+
722
+ it('7. 空 entries → []', () => {
723
+ expect(segmentTurns([], new Set())).toEqual([])
724
+ })
725
+
726
+ it('startTime 取 turn 首条 entry 的 timestamp', () => {
727
+ const entries = [
728
+ { ...msg('U', null, 'user'), timestamp: '2026-05-28T03:17:12.844Z' },
729
+ msg('A', 'U', 'assistant'),
730
+ ]
731
+ const turns = segmentTurns(entries, new Set(['U', 'A']))
732
+ expect(turns[0].startTime).toBe('2026-05-28T03:17:12.844Z')
733
+ })
734
+
735
+ it('leaf 视图分段结构形态:preface(首 user 前的 model_change)+ user turns + compaction turn', () => {
736
+ // 合成分段结构形态(1 preface + 1 compaction + 2 user turn):
737
+ // 规则 2(compaction 独立成 turn)+ 规则 4(首 user 前的 model_change/thinking_level_change
738
+ // 成 preface turn,无 userEntry)
739
+ const entries = [
740
+ entry('MC', null, 'model_change'),
741
+ msg('U1', 'MC', 'user'),
742
+ msg('A1', 'U1', 'assistant'),
743
+ entry('C1', 'A1', 'compaction'),
744
+ msg('U2', 'C1', 'user'),
745
+ msg('A2', 'U2', 'assistant'),
746
+ ]
747
+ const tree = buildTreeView(entries)
748
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
112
749
 
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([])
750
+ expect(turns.length).toBe(4)
751
+ // 首 turn 是 preface(model_change),无 userEntry、非 compaction
752
+ expect(turns[0].userEntry).toBeUndefined()
753
+ expect(turns[0].isCompaction).toBe(false)
754
+ expect(turns.filter((t) => t.isCompaction).length).toBe(1)
755
+ expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(2)
756
+ })
120
757
  })
121
- })
758
+ });