@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.
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/__tests__/family.test.ts +207 -0
- package/src/__tests__/find.test.ts +214 -0
- package/src/__tests__/hash-provider.test.ts +498 -0
- package/src/__tests__/index.test.ts +193 -0
- package/src/__tests__/parser.test.ts +166 -0
- package/src/__tests__/real-data.ts +53 -0
- package/src/__tests__/render.test.ts +367 -0
- package/src/__tests__/roots.test.ts +132 -0
- package/src/__tests__/session-command.test.ts +198 -0
- package/src/__tests__/subagents.test.ts +430 -0
- package/src/__tests__/tool-handler.test.ts +510 -0
- package/src/__tests__/toolcall.test.ts +256 -0
- package/src/__tests__/tree.test.ts +101 -0
- package/src/__tests__/turns.test.ts +142 -0
- package/src/core/family.ts +240 -0
- package/src/core/parser.ts +160 -0
- package/src/core/render.ts +584 -0
- package/src/core/toolcall.ts +168 -0
- package/src/core/tree.ts +93 -0
- package/src/core/turns.ts +97 -0
- package/src/discovery/find.ts +247 -0
- package/src/discovery/roots.ts +92 -0
- package/src/discovery/subagents.ts +470 -0
- package/src/index.ts +189 -0
- package/src/tool-handler.ts +1160 -0
- package/src/tui/hash-provider.ts +230 -0
- package/src/tui/session-command.ts +85 -0
- package/vitest.config.ts +7 -0
|
@@ -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
|
+
})
|