@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,256 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { extractToolCalls, formatToolCallSummary } from '../core/toolcall.js'
3
+ import type { Entry } from '../core/parser.js'
4
+
5
+ /**
6
+ * D1 映射表(design §3.3)确定性测试:formatToolCallSummary 12 个 switch 分支
7
+ * + extractToolCalls 提取/coerceArgs 兜底各形态。修复 MF-2(原仅经 render.test.ts
8
+ * fixture 间接覆盖 bash/read/edit/write 四分支,head/todo/subagent/cw/unknown 零直接覆盖)。
9
+ */
10
+
11
+ /** 构造带 content 的 assistant message entry。 */
12
+ function entryWithContent(content: unknown): Entry {
13
+ return { type: 'message', id: 'm1', parentId: null, message: { role: 'assistant', content } }
14
+ }
15
+
16
+ describe('formatToolCallSummary — bash/read/edit/write', () => {
17
+ it('bash:有 command → 摘要(超 60 字截断加省略号);无 command → 仅工具名', () => {
18
+ expect(formatToolCallSummary({ id: 'c1', name: 'bash', arguments: { command: 'ls -la' } })).toBe(
19
+ 'bash: ls -la',
20
+ )
21
+ // 60 字边界:恰好 60 不截断
22
+ expect(
23
+ formatToolCallSummary({
24
+ id: 'c1',
25
+ name: 'bash',
26
+ arguments: { command: 'y'.repeat(60) },
27
+ }),
28
+ ).toBe(`bash: ${'y'.repeat(60)}`)
29
+ // 61 字 → 前 60 + 省略号
30
+ expect(
31
+ formatToolCallSummary({
32
+ id: 'c1',
33
+ name: 'bash',
34
+ arguments: { command: 'z'.repeat(61) },
35
+ }),
36
+ ).toBe(`bash: ${'z'.repeat(60)}…`)
37
+ expect(formatToolCallSummary({ id: 'c1', name: 'bash', arguments: {} })).toBe('bash')
38
+ })
39
+
40
+ it('read:有 path → basename(尾斜杠剥除);无 path → 仅工具名', () => {
41
+ expect(
42
+ formatToolCallSummary({ id: 'c1', name: 'read', arguments: { path: '/a/b/c.ts' } }),
43
+ ).toBe('read: c.ts')
44
+ // 尾斜杠:basename 先剥 / 再取末段
45
+ expect(formatToolCallSummary({ id: 'c1', name: 'read', arguments: { path: '/a/b/' } })).toBe(
46
+ 'read: b',
47
+ )
48
+ expect(formatToolCallSummary({ id: 'c1', name: 'read', arguments: {} })).toBe('read')
49
+ })
50
+
51
+ it('edit:path + edits 数组 → blocks 数;缺 path / 缺 edits 各自降级', () => {
52
+ const base = { path: '/p/f.ts' }
53
+ expect(
54
+ formatToolCallSummary({
55
+ id: 'c1',
56
+ name: 'edit',
57
+ arguments: { ...base, edits: [{ a: 1 }, { a: 2 }, { a: 3 }] },
58
+ }),
59
+ ).toBe('edit: f.ts (3 blocks)')
60
+ expect(formatToolCallSummary({ id: 'c1', name: 'edit', arguments: base })).toBe('edit: f.ts')
61
+ // 无 path 有 edits → head 降级为工具名,保留 blocks
62
+ expect(
63
+ formatToolCallSummary({ id: 'c1', name: 'edit', arguments: { edits: [1, 2] } }),
64
+ ).toBe('edit (2 blocks)')
65
+ // edits 非数组(object)→ 无 blocks 维度
66
+ expect(
67
+ formatToolCallSummary({ id: 'c1', name: 'edit', arguments: { ...base, edits: { a: 1 } } }),
68
+ ).toBe('edit: f.ts')
69
+ })
70
+
71
+ it('write:content utf8 字节转 KB(<1KB 取 1,>1KB 四舍五入);无 content → 仅 head', () => {
72
+ const base = { path: '/p/f.ts' }
73
+ // 100 字节 < 1KB → 下限 1KB
74
+ expect(
75
+ formatToolCallSummary({
76
+ id: 'c1',
77
+ name: 'write',
78
+ arguments: { ...base, content: 'a'.repeat(100) },
79
+ }),
80
+ ).toBe('write: f.ts (1KB)')
81
+ // 2048 字节 = 2KB
82
+ expect(
83
+ formatToolCallSummary({
84
+ id: 'c1',
85
+ name: 'write',
86
+ arguments: { ...base, content: 'a'.repeat(2048) },
87
+ }),
88
+ ).toBe('write: f.ts (2KB)')
89
+ // 多字节字符按 utf8 字节计('你' = 3 字节,1024 个 = 3KB)
90
+ expect(
91
+ formatToolCallSummary({
92
+ id: 'c1',
93
+ name: 'write',
94
+ arguments: { ...base, content: '你'.repeat(1024) },
95
+ }),
96
+ ).toBe('write: f.ts (3KB)')
97
+ // 无 path 有 content → head 降级为工具名
98
+ expect(
99
+ formatToolCallSummary({ id: 'c1', name: 'write', arguments: { content: 'abc' } }),
100
+ ).toBe('write (1KB)')
101
+ expect(formatToolCallSummary({ id: 'c1', name: 'write', arguments: base })).toBe('write: f.ts')
102
+ })
103
+ })
104
+
105
+ describe('formatToolCallSummary — head/todo/subagent', () => {
106
+ it('head:limit 双形态(number/string)都输出;缺 path / 缺 limit 各自降级', () => {
107
+ const base = { path: '/p/f.ts' }
108
+ expect(formatToolCallSummary({ id: 'c1', name: 'head', arguments: { ...base, limit: 5 } })).toBe(
109
+ 'head: f.ts (5)',
110
+ )
111
+ // string limit(实测 number,防御 string)
112
+ expect(
113
+ formatToolCallSummary({ id: 'c1', name: 'head', arguments: { ...base, limit: '5' } }),
114
+ ).toBe('head: f.ts (5)')
115
+ expect(formatToolCallSummary({ id: 'c1', name: 'head', arguments: base })).toBe('head: f.ts')
116
+ // 无 path 有 limit → head 降级
117
+ expect(formatToolCallSummary({ id: 'c1', name: 'head', arguments: { limit: 5 } })).toBe(
118
+ 'head (5)',
119
+ )
120
+ })
121
+
122
+ it('todo:id 双形态(number/string);缺 action / 缺 id 各自降级', () => {
123
+ expect(
124
+ formatToolCallSummary({ id: 'c1', name: 'todo', arguments: { action: 'add', id: 3 } }),
125
+ ).toBe('todo: add(3)')
126
+ expect(
127
+ formatToolCallSummary({ id: 'c1', name: 'todo', arguments: { action: 'add', id: 'abc' } }),
128
+ ).toBe('todo: add(abc)')
129
+ expect(formatToolCallSummary({ id: 'c1', name: 'todo', arguments: { action: 'add' } })).toBe(
130
+ 'todo: add',
131
+ )
132
+ expect(formatToolCallSummary({ id: 'c1', name: 'todo', arguments: {} })).toBe('todo')
133
+ })
134
+
135
+ it('subagent:task 截断 40 字;无 task → 仅工具名', () => {
136
+ expect(
137
+ formatToolCallSummary({ id: 'c1', name: 'subagent', arguments: { task: 'do work' } }),
138
+ ).toBe('subagent: do work')
139
+ expect(
140
+ formatToolCallSummary({
141
+ id: 'c1',
142
+ name: 'subagent',
143
+ arguments: { task: 'x'.repeat(41) },
144
+ }),
145
+ ).toBe(`subagent: ${'x'.repeat(40)}…`)
146
+ expect(formatToolCallSummary({ id: 'c1', name: 'subagent', arguments: {} })).toBe('subagent')
147
+ })
148
+ })
149
+
150
+ describe('formatToolCallSummary — coding-workflow-* / unknown fallback', () => {
151
+ it('coding-workflow-gate:phase 存在(任意类型 String() 化);缺 phase → 仅工具名', () => {
152
+ expect(
153
+ formatToolCallSummary({
154
+ id: 'c1',
155
+ name: 'coding-workflow-gate',
156
+ arguments: { phase: 'prompt' },
157
+ }),
158
+ ).toBe('cw-gate: phase=prompt')
159
+ // 非 string phase(number)→ String() 化
160
+ expect(
161
+ formatToolCallSummary({ id: 'c1', name: 'coding-workflow-gate', arguments: { phase: 2 } }),
162
+ ).toBe('cw-gate: phase=2')
163
+ expect(
164
+ formatToolCallSummary({ id: 'c1', name: 'coding-workflow-gate', arguments: {} }),
165
+ ).toBe('cw-gate')
166
+ })
167
+
168
+ it('coding-workflow-init:slug 存在 / 缺失', () => {
169
+ expect(
170
+ formatToolCallSummary({
171
+ id: 'c1',
172
+ name: 'coding-workflow-init',
173
+ arguments: { slug: 'abc' },
174
+ }),
175
+ ).toBe('cw-init: abc')
176
+ expect(
177
+ formatToolCallSummary({ id: 'c1', name: 'coding-workflow-init', arguments: {} }),
178
+ ).toBe('cw-init')
179
+ })
180
+
181
+ it('coding-workflow-phase-start:恒为 cw-phase-start(忽略参数)', () => {
182
+ expect(
183
+ formatToolCallSummary({ id: 'c1', name: 'coding-workflow-phase-start', arguments: {} }),
184
+ ).toBe('cw-phase-start')
185
+ expect(
186
+ formatToolCallSummary({
187
+ id: 'c1',
188
+ name: 'coding-workflow-phase-start',
189
+ arguments: { phase: 'x' },
190
+ }),
191
+ ).toBe('cw-phase-start')
192
+ })
193
+
194
+ it('unknown fallback:空 arguments → 仅工具名;非空 → JSON 前 50 字截断;循环引用 → 仅工具名', () => {
195
+ expect(formatToolCallSummary({ id: 'c1', name: 'my-tool', arguments: {} })).toBe('my-tool')
196
+ // JSON 16 字 ≤ 50 → 不截断
197
+ expect(
198
+ formatToolCallSummary({ id: 'c1', name: 'my-tool', arguments: { long: 'value' } }),
199
+ ).toBe('my-tool: {"long":"value"}')
200
+ // JSON 68 字 > 50 → 前 50 + 省略号('{"a":"' 6 字 + 44 x)
201
+ expect(
202
+ formatToolCallSummary({
203
+ id: 'c1',
204
+ name: 'my-tool',
205
+ arguments: { a: 'x'.repeat(60) },
206
+ }),
207
+ ).toBe(`my-tool: {"a":"${'x'.repeat(44)}…`)
208
+ // JSON.stringify 抛错(循环引用)→ 仅工具名
209
+ const circ: Record<string, unknown> = { a: 1 }
210
+ circ.self = circ
211
+ expect(formatToolCallSummary({ id: 'c1', name: 'my-tool', arguments: circ })).toBe('my-tool')
212
+ })
213
+ })
214
+
215
+ describe('extractToolCalls — 提取 + coerceArgs 兜底', () => {
216
+ it('无 message / content 非数组 → 空数组', () => {
217
+ expect(extractToolCalls({ type: 'message', id: 'm1', parentId: null })).toEqual([])
218
+ expect(extractToolCalls(entryWithContent('plain text'))).toEqual([])
219
+ expect(extractToolCalls(entryWithContent([]))).toEqual([])
220
+ })
221
+
222
+ it('过滤非 toolCall block / 缺 id / 缺 name 的 block', () => {
223
+ const content = [
224
+ null,
225
+ 'str',
226
+ { type: 'text', text: 'hi' },
227
+ { type: 'toolCall', name: 'bash' }, // 缺 id
228
+ { type: 'toolCall', id: 42, name: 'bash' }, // id 非 string
229
+ { type: 'toolCall', id: 'c2' }, // 缺 name
230
+ { type: 'toolCall', id: 'c1', name: 'bash', arguments: { command: 'ls' } },
231
+ ]
232
+ const out = extractToolCalls(entryWithContent(content))
233
+ expect(out).toHaveLength(1)
234
+ expect(out[0]).toEqual({ id: 'c1', name: 'bash', arguments: { command: 'ls' } })
235
+ })
236
+
237
+ it('coerceArgs:object 原样;string JSON 解析;非法 string / number / null / array / boolean → {}', () => {
238
+ const blocks = (args: unknown) => [{ type: 'toolCall', id: 'c1', name: 't', arguments: args }]
239
+ // object 原样
240
+ expect(extractToolCalls(entryWithContent(blocks({ a: 1 })))[0].arguments).toEqual({ a: 1 })
241
+ // string JSON → 解析
242
+ expect(
243
+ extractToolCalls(entryWithContent(blocks('{"command":"ls"}')))[0].arguments,
244
+ ).toEqual({ command: 'ls' })
245
+ // 非法 JSON → {}
246
+ expect(extractToolCalls(entryWithContent(blocks('not-json')))[0].arguments).toEqual({})
247
+ // JSON 解析结果非 object('42' → number)→ {}
248
+ expect(extractToolCalls(entryWithContent(blocks('42')))[0].arguments).toEqual({})
249
+ // number / null / array / boolean / undefined → {}
250
+ expect(extractToolCalls(entryWithContent(blocks(5)))[0].arguments).toEqual({})
251
+ expect(extractToolCalls(entryWithContent(blocks(null)))[0].arguments).toEqual({})
252
+ expect(extractToolCalls(entryWithContent(blocks([1, 2])))[0].arguments).toEqual({})
253
+ expect(extractToolCalls(entryWithContent(blocks(true)))[0].arguments).toEqual({})
254
+ expect(extractToolCalls(entryWithContent(blocks(undefined)))[0].arguments).toEqual({})
255
+ })
256
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildTreeView } from '../core/tree.js'
3
+ import type { Entry } from '../core/parser.js'
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
+ })
19
+
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)
24
+
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
+ })
30
+
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
+ })
45
+
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)
50
+
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
+ })
56
+
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
+ })
70
+
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 变旁支
74
+
75
+ const t1 = buildTreeView(ordered)
76
+ const t2 = buildTreeView(swapped)
77
+
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)
82
+ })
83
+
84
+ it('空 entries:返回空视图', () => {
85
+ const t = buildTreeView([])
86
+ expect(t.leafPath).toEqual([])
87
+ expect(t.branches.size).toBe(0)
88
+ expect(t.orphans).toEqual([])
89
+ })
90
+
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)
95
+
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([])
100
+ })
101
+ })
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { segmentTurns } from '../core/turns.js'
3
+ import type { Entry } from '../core/parser.js'
4
+ import { parseSessionFile } from '../core/parser.js'
5
+ import { buildTreeView } from '../core/tree.js'
6
+ import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
7
+
8
+ // ---- Entry 构造助手(turns.ts 只消费 type/id/parentId/message.role) ----
9
+
10
+ function msg(
11
+ id: string,
12
+ parentId: string | null,
13
+ role: 'user' | 'assistant' | 'toolResult',
14
+ ): Entry {
15
+ return { type: 'message', id, parentId, message: { role, content: '' } }
16
+ }
17
+
18
+ function entry(id: string, parentId: string | null, type: string): Entry {
19
+ return { type, id, parentId }
20
+ }
21
+
22
+ describe('segmentTurns', () => {
23
+ it('1. user 开 turn,后续 assistant/toolResult 并入同一 turn', () => {
24
+ const entries = [msg('U', null, 'user'), msg('A', 'U', 'assistant'), msg('T', 'A', 'toolResult')]
25
+ const turns = segmentTurns(entries, new Set(['U', 'A', 'T']))
26
+
27
+ expect(turns).toHaveLength(1)
28
+ expect(turns[0].index).toBe(0)
29
+ expect(turns[0].isCompaction).toBe(false)
30
+ expect(turns[0].userEntry?.id).toBe('U')
31
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A', 'T'])
32
+ })
33
+
34
+ it('2. compaction 开新 turn(isCompaction=true,无 userEntry),后续 user 又开一个 turn', () => {
35
+ const entries = [
36
+ msg('U', null, 'user'),
37
+ msg('A', 'U', 'assistant'),
38
+ entry('C', 'A', 'compaction'),
39
+ msg('U2', 'C', 'user'),
40
+ ]
41
+ const turns = segmentTurns(entries, new Set(['U', 'A', 'C', 'U2']))
42
+
43
+ expect(turns).toHaveLength(3)
44
+ // turn 0:user + assistant
45
+ expect(turns[0].userEntry?.id).toBe('U')
46
+ expect(turns[0].isCompaction).toBe(false)
47
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A'])
48
+ // turn 1:compaction 独立成 turn
49
+ expect(turns[1].isCompaction).toBe(true)
50
+ expect(turns[1].userEntry).toBeUndefined()
51
+ expect(turns[1].entries.map((e) => e.id)).toEqual(['C'])
52
+ // turn 2:新 user
53
+ expect(turns[2].userEntry?.id).toBe('U2')
54
+ expect(turns[2].isCompaction).toBe(false)
55
+ })
56
+
57
+ it('3. model_change / thinking_level_change / custom 并入当前 turn', () => {
58
+ const entries = [
59
+ msg('U', null, 'user'),
60
+ entry('M', 'U', 'model_change'),
61
+ entry('T', 'M', 'thinking_level_change'),
62
+ entry('X', 'T', 'custom'),
63
+ ]
64
+ const turns = segmentTurns(entries, new Set(['U', 'M', 'T', 'X']))
65
+
66
+ expect(turns).toHaveLength(1)
67
+ expect(turns[0].userEntry?.id).toBe('U')
68
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'M', 'T', 'X'])
69
+ })
70
+
71
+ it('4. branch entry(不在 leafSet)不计入', () => {
72
+ const entries = [
73
+ msg('U', null, 'user'),
74
+ msg('A', 'U', 'assistant'),
75
+ msg('B1', 'U', 'assistant'), // 旁支:不在 leafSet
76
+ msg('B2', 'B1', 'toolResult'), // 旁支子节点
77
+ ]
78
+ const turns = segmentTurns(entries, new Set(['U', 'A']))
79
+
80
+ expect(turns).toHaveLength(1)
81
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A'])
82
+ })
83
+
84
+ it('5. 孤儿 assistant(首个 entry 是 assistant 无前置 user)→ 并入 turn 0(preface)', () => {
85
+ const entries = [msg('O', null, 'assistant'), msg('U', 'O', 'user')]
86
+ const turns = segmentTurns(entries, new Set(['O', 'U']))
87
+
88
+ expect(turns).toHaveLength(2)
89
+ // turn 0:preface(孤儿 assistant),无 userEntry,非 compaction
90
+ expect(turns[0].isCompaction).toBe(false)
91
+ expect(turns[0].userEntry).toBeUndefined()
92
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['O'])
93
+ // turn 1:首个 user
94
+ expect(turns[1].userEntry?.id).toBe('U')
95
+ expect(turns[1].index).toBe(1)
96
+ })
97
+
98
+ it('6. session header 被忽略(即使在 leafSet 中也不计 turn)', () => {
99
+ const entries = [
100
+ entry('S', null, 'session'),
101
+ msg('U', 'S', 'user'),
102
+ msg('A', 'U', 'assistant'),
103
+ ]
104
+ // S 也在 leafSet,验证规则 1 优先于规则 5
105
+ const turns = segmentTurns(entries, new Set(['S', 'U', 'A']))
106
+
107
+ expect(turns).toHaveLength(1)
108
+ expect(turns[0].entries.map((e) => e.id)).toEqual(['U', 'A'])
109
+ })
110
+
111
+ it('7. 空 entries → []', () => {
112
+ expect(segmentTurns([], new Set())).toEqual([])
113
+ })
114
+
115
+ it('startTime 取 turn 首条 entry 的 timestamp', () => {
116
+ const entries = [
117
+ { ...msg('U', null, 'user'), timestamp: '2026-05-28T03:17:12.844Z' },
118
+ msg('A', 'U', 'assistant'),
119
+ ]
120
+ const turns = segmentTurns(entries, new Set(['U', 'A']))
121
+ expect(turns[0].startTime).toBe('2026-05-28T03:17:12.844Z')
122
+ })
123
+
124
+ it.skipIf(!HAS_REAL_SESSION)('真实 019e6c96:leaf 视图分段(26 user + 5 compaction + 1 前置 = 32 turn)', async () => {
125
+ // 注:design P-outline / plan T1.3 基线为 26(仅数 user 的旧定义)。
126
+ // 本实现按冻结接口 Turn.isCompaction + design §3.5 算法 3 规则 2(compaction 独立成 turn)
127
+ // + 规则 4(首 user 前的 model_change/thinking_level_change 成 preface turn),
128
+ // 真实数据为 32 turn。26 基线早于 compaction-split 细化,详见交接说明。
129
+ const parsed = await parseSessionFile(REAL_SESSION)
130
+ const tree = buildTreeView(parsed.entries)
131
+ const turns = segmentTurns(parsed.entries, new Set(tree.leafPath))
132
+
133
+ expect(turns.length).toBe(32)
134
+ // 首个 turn 是 preface(model_change/thinking_level_change),无 userEntry
135
+ expect(turns[0].userEntry).toBeUndefined()
136
+ expect(turns[0].isCompaction).toBe(false)
137
+ // 其中有 5 个 compaction turn
138
+ expect(turns.filter((t) => t.isCompaction).length).toBe(5)
139
+ // 其中有 26 个 user turn
140
+ expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(26)
141
+ })
142
+ })