@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,188 +0,0 @@
1
- // 跨包集成([U4c / G4-S5③] record 持久化收敛 P4c 验收锚点):
2
- // subagent-core 重建/终态写侧产出的 manifest → session-reader identity 派生视图不降级。
3
- //
4
- // 断言链(设计 subagent-record-persistence-consolidation D5 前向兼容判定 + S5③):
5
- // 1. manifest 被人为删除(S5 缓存可丢场景)→ subagent-core RecordStore.rebuildIndexes
6
- // 全量重建 → session-reader listRecordManifests 重新可见;
7
- // 2. 重建窗口前后,session-reader identity 派生视图的输入字段集(rootSessionId/slug/
8
- // task/agentName/model/status/sessionFile/parentRecordId)逐字段一致——双写过渡
9
- // 字段(executionStatus)在场且不被本包消费(旧 status 三态派生视图继续生效);
10
- // 3. 重建源 = 事件流折叠 + identity entry([③ .state 退场] 后终态位消失)——
11
- // 身份/富字段逐字段保真;legacy status 不再保真 closed(重建单规则恒 idle +
12
- // 无 closedReason → derivedManifestRecord 如实派生 legacy "running",终态语义
13
- // 由 record-settled 事件帧承载)。
14
- //
15
- // 形态同 cross-package-subagent-core.test.ts:subagent-core 真实 tmpdir 产出磁盘
16
- // 状态,本包零 mock 读同一 tmpdir;不触碰真实数据目录(红线)。
17
-
18
- import * as fs from 'node:fs'
19
- import * as os from 'node:os'
20
- import * as path from 'node:path'
21
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
22
-
23
- import { RecordStore } from '@zhushanwen/subagent-core'
24
- // barrel 外符号按包名 .ts 深路径消费(vitest alias 重写,同 cross-package 先例)
25
- import { createRecord } from '@zhushanwen/subagent-core/execution/persistence/execution-record.ts'
26
- import {
27
- getSubagentRecordsDir,
28
- getSubagentSessionDir,
29
- } from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
30
-
31
- import { listRecordManifests, type RecordManifest } from '../discovery/subagents.js'
32
-
33
- const ROOT_SESSION = 'root-dual-write'
34
-
35
- /** session-reader identity 派生视图(manifestIdentityData)消费的字段集——重建窗口
36
- * 前后必须逐字段一致(降级 = 任一字段缺失/漂移)。 */
37
- function identityView(m: RecordManifest) {
38
- return {
39
- id: m.id,
40
- rootSessionId: m.rootSessionId,
41
- agentName: m.agentName,
42
- task: m.task,
43
- slug: m.slug,
44
- model: m.model,
45
- status: m.status,
46
- sessionFile: m.sessionFile,
47
- parentRecordId: m.parentRecordId,
48
- }
49
- }
50
-
51
- describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → session-reader 投影不降级', () => {
52
- let dataDir: string
53
- let agentDir: string
54
- let sessionsDir: string
55
- let recordsDir: string
56
- let childFile: string
57
-
58
- beforeEach(() => {
59
- // 生产拓扑复刻([session-store-layout-inversion 域 9]):agentDir = <dataDir>/agent
60
- //(session-reader 的 agentDir 语义),subagent-core 写侧经新公式落存储树
61
- // <dataDir>/ext-sessions/<rootSid>/subagents/——listRecordManifests 双根扫描的新根
62
- //(dirname(agentDir)/ext-sessions)与写侧落点相遇
63
- dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-rebuild-dualwrite-'))
64
- agentDir = path.join(dataDir, 'agent')
65
- sessionsDir = getSubagentSessionDir(ROOT_SESSION, dataDir)
66
- recordsDir = getSubagentRecordsDir(ROOT_SESSION, dataDir)
67
- fs.mkdirSync(sessionsDir, { recursive: true })
68
- fs.mkdirSync(recordsDir, { recursive: true })
69
-
70
- // 子 session 文件:session header + model_change(light 重建的 model 源——
71
- // identity 前途经 entry best-effort 提取)+ identity entry(session-reader
72
- // 步骤 2 首行 header 探测 + manifest 主路径的数据锚)
73
- childFile = path.join(sessionsDir, '20260912T000000_sa-dw.jsonl')
74
- const ts = new Date(1000).toISOString()
75
- fs.writeFileSync(
76
- childFile,
77
- [
78
- JSON.stringify({ type: 'session', version: 3, id: 'sess-sa-dw', timestamp: ts, cwd: agentDir }),
79
- JSON.stringify({ type: 'model_change', id: 'mc-1', parentId: null, timestamp: ts, provider: 'prov', modelId: 'm1' }),
80
- JSON.stringify({
81
- type: 'custom',
82
- id: 'id-1',
83
- parentId: null,
84
- timestamp: ts,
85
- customType: 'subagent-identity',
86
- data: {
87
- id: 'sa-dw',
88
- agent: 'worker',
89
- mode: 'background',
90
- task: 'dual write task',
91
- slug: 'dual',
92
- startedAt: 1000,
93
- rootSessionId: ROOT_SESSION,
94
- parentRecordId: 'sa-parent',
95
- depth: 1,
96
- },
97
- }),
98
- ].join('\n') + '\n',
99
- 'utf-8',
100
- )
101
- })
102
-
103
- afterEach(() => {
104
- fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
105
- })
106
-
107
- it('S5③: manifest 删除 → rebuildIndexes 重建 → identity 派生视图字段集前后一致(双写字段在场)', async () => {
108
- // ── 初始 manifest:subagent-core 终态写侧(markFinalized)真实产出 ──
109
- const record = createRecord('sa-dw', {
110
- agent: 'worker',
111
- model: 'prov/m1',
112
- mode: 'background',
113
- task: 'dual write task',
114
- slug: 'dual',
115
- startedAt: 1000,
116
- rootSessionId: ROOT_SESSION,
117
- parentRecordId: 'sa-parent',
118
- depth: 1,
119
- chatMode: false,
120
- controller: new AbortController(),
121
- })
122
- Object.assign(record, {
123
- sessionFile: childFile,
124
- status: 'closed' as const,
125
- closedReason: 'gc' as const,
126
- endedAt: 2000,
127
- })
128
- // [u-subagent dir 晚绑定] RecordStore 构造 = 目录取值函数(非目录字符串)
129
- const writer = new RecordStore(() => sessionsDir, undefined, undefined, () => recordsDir)
130
- expect(writer.markFinalized(record, 'gc')).toBe(true)
131
- writer.dispose()
132
-
133
- const before = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
134
- expect(before).toBeDefined()
135
- const viewBefore = identityView(before!)
136
-
137
- // 双写过渡字段在写侧就位(本包不消费,但前向兼容锚要求其在场)。
138
- // [U2 两态] executionStatus 是内部权威词汇(running|idle 两态,终态概念删除)
139
- // → 终态化后恒 'idle';status 旧三态是 session-reader 直读派生视图(永久保留)→ 'closed'。
140
- const rawBefore = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
141
- expect(rawBefore.executionStatus).toBe('idle')
142
- expect(rawBefore.status).toBe('closed')
143
-
144
- // ── S5 场景:人为删除全部 manifest(缓存可丢)→ 全量重建 ──
145
- for (const f of fs.readdirSync(recordsDir)) fs.rmSync(path.join(recordsDir, f))
146
- expect((await listRecordManifests(agentDir)).filter((m) => m.id === 'sa-dw')).toHaveLength(0)
147
-
148
- const rebuilder = new RecordStore(() => sessionsDir, undefined, undefined, () => recordsDir)
149
- expect(rebuilder.rebuildIndexes()).toBe(1)
150
- rebuilder.dispose()
151
-
152
- // ── session-reader 视角:重建后 identity 派生视图不降级 ──
153
- const after = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
154
- expect(after).toBeDefined()
155
- // 字段集保真(身份/富字段逐字段一致);仅 legacy status 按 ③ 后重建语义如实
156
- // 派生 running(终态权威 = 事件流 record-settled 帧,磁盘重建无 closedReason 载体)。
157
- expect(identityView(after!)).toEqual({ ...viewBefore, status: 'running' })
158
-
159
- const rawAfter = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
160
- expect(rawAfter.status).toBe('running')
161
- expect(rawAfter.executionStatus).toBe('idle')
162
- // closedReason 无重建载体(`.state` 退场)→ 如实缺席。
163
- expect(rawAfter.closedReason).toBeUndefined()
164
- })
165
-
166
- it('惰性通道:查询结果(collectRecords)补建的 manifest 同样可被本包读取派生视图', async () => {
167
- // 不经终态写侧(无 manifest 初始形态):磁盘 record 只有 identity + 无 sidecar
168
- // → 查询触发惰性补建。
169
- const store = new RecordStore(() => sessionsDir, undefined, undefined, () => recordsDir)
170
- expect(store.collectRecords(10).map((r) => r.id)).toContain('sa-dw')
171
- store.dispose()
172
-
173
- const manifests = await listRecordManifests(agentDir)
174
- const found = manifests.find((m) => m.id === 'sa-dw')
175
- expect(found).toBeDefined()
176
- expect(found!.status).toBe('running')
177
- expect(found!.task).toBe('dual write task')
178
- expect(found!.agentName).toBe('worker')
179
- expect(found!.sessionFile).toBe(childFile)
180
- expect(found!.parentRecordId).toBe('sa-parent')
181
- // 双写过渡字段([U3 重建单规则] 无 sidecar 重建一律 idle:executionStatus 如实
182
- // 派生两态词汇;legacy status 经桥接判据派生——无 closedReason 遗留位的 idle
183
- // 派生 "running"(session-reader 视角的活跃成员,§3.2.8 下行映射)。
184
- const raw = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
185
- expect(raw.executionStatus).toBe('idle')
186
- expect(raw.status).toBe('running')
187
- })
188
- })
@@ -1,481 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { renderOutline, renderExpand, renderDetail, type ToolResultSummaryEntry } from '../core/render.js'
3
- import type { Entry } from '@zhushanwen/session-core'
4
- import type { Turn } from '../core/turns.js'
5
- import type { TreeView } from '../core/tree.js'
6
-
7
- // ---- 构造助手 ----
8
-
9
- function uEntry(id: string, text: string): Entry {
10
- return {
11
- type: 'message',
12
- id,
13
- parentId: null,
14
- message: { role: 'user', content: [{ type: 'text', text }] },
15
- }
16
- }
17
-
18
- function aEntry(
19
- id: string,
20
- text: string,
21
- toolCalls?: Array<{ name: string; id?: string; arguments?: Record<string, unknown> }>,
22
- thinking?: string,
23
- ): Entry {
24
- // v2:toolCalls 进 content 的 toolCall block(真实结构,probe 实测 519),不再设 message.toolCalls
25
- const content: unknown[] = [{ type: 'text', text }]
26
- if (thinking !== undefined) content.push({ type: 'thinking', thinking })
27
- if (toolCalls !== undefined) {
28
- for (const tc of toolCalls) {
29
- content.push({
30
- type: 'toolCall',
31
- id: tc.id ?? `call_${tc.name}`,
32
- name: tc.name,
33
- arguments: tc.arguments ?? {},
34
- })
35
- }
36
- }
37
- return { type: 'message', id, parentId: null, message: { role: 'assistant', content } }
38
- }
39
-
40
- function tEntry(
41
- id: string,
42
- content: string,
43
- opts?: { toolCallId?: string; toolName?: string },
44
- ): Entry {
45
- // v2:content 保持 string(测试简化,toolResultText 对 string/数组都处理);
46
- // 加 toolCallId/toolName 供 O2 类型化摘要关联测试
47
- const message: NonNullable<Entry['message']> = { role: 'toolResult', content }
48
- if (opts?.toolName !== undefined) message.toolName = opts.toolName
49
- if (opts?.toolCallId !== undefined) message.toolCallId = opts.toolCallId
50
- return { type: 'message', id, parentId: null, message }
51
- }
52
-
53
- function turn(
54
- idx: number,
55
- entries: Entry[],
56
- opts: { userEntry?: Entry; isCompaction?: boolean; startTime?: string } = {},
57
- ): Turn {
58
- const t: Turn = {
59
- index: idx,
60
- entries,
61
- userEntry: opts.userEntry,
62
- isCompaction: opts.isCompaction ?? false,
63
- }
64
- if (opts.startTime !== undefined) t.startTime = opts.startTime
65
- return t
66
- }
67
-
68
- function emptyTree(): TreeView {
69
- return { leafPath: [], branches: new Map(), orphans: [] }
70
- }
71
-
72
- describe('renderOutline', () => {
73
- it('1. 预算充足 → 全部字段完整渲染(userBrief 截断 / toolSummary 聚合 / omittedBytes 正确)', () => {
74
- const t = turn(0, [
75
- uEntry('U', 'do something'),
76
- aEntry('A', 'sure', [{ name: 'bash' }, { name: 'bash' }, { name: 'read' }, { name: 'read' }], 'secret'),
77
- tEntry('T', 'output'),
78
- ], { userEntry: uEntry('U', 'do something') })
79
-
80
- const result = renderOutline([t], emptyTree(), { budget: 2000 })
81
- const b = result.turns[0]
82
-
83
- expect(b.userBrief).toBe('do something') // 12 chars,未截断
84
- expect(b.toolSummary).toBe('bash×2,read×2')
85
- expect(b.assistantBrief).toBe('sure')
86
- // thinking 'secret'(6B) + toolResult 'output'(6B)
87
- expect(b.omittedBytes).toBe(12)
88
- expect(result.truncated).toBeUndefined()
89
- // E7 行渲染统一:lines 随结果返回,与 turns 一一对应(预算度量 = 展示行同一份)
90
- expect(result.lines).toEqual(['T000 · do something · bash×2,read×2 · → sure · [12B omitted]'])
91
- })
92
-
93
- it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
94
- // 预算紧张降级后 L1 行不含 assistantBrief(预算充足 level 0 行含);budget=40, 3 turns → perTurnCharBudget≈53
95
- // 行 = head + userBrief(10) + toolSummary(20 个工具名≈63 chars) → ~80 > 53 → 砍 toolSummary
96
- const toolCalls = Array.from({ length: 20 }, (_, k) => ({ name: `t${String(k).padStart(2, '0')}` }))
97
- const turns = [0, 1, 2].map((i) =>
98
- turn(i, [uEntry(`U${i}`, 'u'.repeat(10)), aEntry(`A${i}`, 'a'.repeat(10), toolCalls)], {
99
- userEntry: uEntry(`U${i}`, 'u'.repeat(10)),
100
- }),
101
- )
102
- const result = renderOutline(turns, emptyTree(), { budget: 40 })
103
-
104
- expect(result.turns).toHaveLength(3)
105
- for (const b of result.turns) {
106
- expect(b.toolSummary).toBe('') // 被砍(降级)
107
- expect(b.userBrief).toBe('u'.repeat(10)) // 骨架保留
108
- }
109
- // E7:降级后的渲染行(骨架形态)与 briefs 字段同步——行内无 → 结论段、无工具聚合段
110
- expect(result.lines).toHaveLength(3)
111
- expect(result.lines).toEqual(['T000 · uuuuuuuuuu', 'T001 · uuuuuuuuuu', 'T002 · uuuuuuuuuu'])
112
- expect(result.truncated).toBeUndefined()
113
- })
114
-
115
- it('2b. 降级(砍 assistantBrief 档):行内 → 结论段消失、toolSummary 保留,与改前语义一致', () => {
116
- // budget=20 → perTurnCharBudget=80;level 0 行 = T000 · u*10 · bash×2 · → a*80… ≈112 > 80
117
- // → 砍 assistantBrief 后 ≈26 ≤ 80 停(S3:该档形态与改前 handler 侧渲染降级后 briefs 等价)
118
- const t = turn(
119
- 0,
120
- [uEntry('U', 'u'.repeat(10)), aEntry('A', 'a'.repeat(100), [{ name: 'bash' }, { name: 'bash' }])],
121
- { userEntry: uEntry('U', 'u'.repeat(10)) },
122
- )
123
- const result = renderOutline([t], emptyTree(), { budget: 20 })
124
-
125
- expect(result.turns[0].assistantBrief).toBe('') // 被砍
126
- expect(result.turns[0].toolSummary).toBe('bash×2') // 保留
127
- expect(result.lines).toEqual(['T000 · uuuuuuuuuu · bash×2'])
128
- expect(result.truncated).toBeUndefined()
129
- })
130
-
131
- it('3. 总超预算 → truncated 计数 > 0(从尾部丢弃)', () => {
132
- // budget=10, 5 turns → 每行降级到骨架 17 chars;total 85/4=21>10 → 截断保留 2,truncated=3
133
- const turns = [0, 1, 2, 3, 4].map((i) =>
134
- turn(i, [uEntry(`U${i}`, 'u'.repeat(10)), aEntry(`A${i}`, 'a'.repeat(10))], {
135
- userEntry: uEntry(`U${i}`, 'u'.repeat(10)),
136
- }),
137
- )
138
- const result = renderOutline(turns, emptyTree(), { budget: 10 })
139
-
140
- expect(result.truncated).toBe(3)
141
- expect(result.turns).toHaveLength(2)
142
- // E7:lines 与 turns 同步截短(truncateToTotalBudget 双数组一致)
143
- expect(result.lines).toHaveLength(2)
144
- })
145
-
146
- it('4. toolSummary 聚合:同名计数 ×N,不同名逗号分隔', () => {
147
- const t = turn(
148
- 0,
149
- [uEntry('U', 'q'), aEntry('A', 'r', [{ name: 'bash' }, { name: 'bash' }, { name: 'read' }, { name: 'read' }, { name: 'edit' }])],
150
- { userEntry: uEntry('U', 'q') },
151
- )
152
- const result = renderOutline([t], emptyTree(), { budget: 2000 })
153
- expect(result.turns[0].toolSummary).toBe('bash×2,read×2,edit')
154
- })
155
-
156
- it('5. omittedBytes = toolResult content + thinking 字节和', () => {
157
- const t = turn(
158
- 0,
159
- [uEntry('U', 'q'), aEntry('A', 'r', undefined, 'th'), tEntry('T', 'out')],
160
- { userEntry: uEntry('U', 'q') },
161
- )
162
- const result = renderOutline([t], emptyTree(), { budget: 2000 })
163
- // thinking 'th'(2B) + toolResult 'out'(3B) = 5
164
- expect(result.turns[0].omittedBytes).toBe(5)
165
- })
166
-
167
- it('6. granularity:entry → 不聚合,每 entry 一行', () => {
168
- const turns = [
169
- turn(0, [uEntry('U0', 'a'), aEntry('A0', 'b')], { userEntry: uEntry('U0', 'a') }),
170
- turn(1, [uEntry('U1', 'c')], { userEntry: uEntry('U1', 'c') }),
171
- ]
172
- const result = renderOutline(turns, emptyTree(), { granularity: 'entry', budget: 2000 })
173
- // 3 leaf entries → 3 行
174
- expect(result.turns).toHaveLength(3)
175
- expect(result.turns.map((b) => b.index)).toEqual([0, 1, 2])
176
- })
177
-
178
- it('7. allBranches:true → forkPoint 处标注 branch', () => {
179
- const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
180
- const userEntry = uEntry('U', 'hi')
181
- const turns = [turn(0, [userEntry], { userEntry })]
182
-
183
- const withBranches = renderOutline(turns, tree, { allBranches: true })
184
- expect(withBranches.turns[0].branch).toBe('U')
185
-
186
- const withoutBranches = renderOutline(turns, tree, { allBranches: false })
187
- expect(withoutBranches.turns[0].branch).toBeUndefined()
188
- })
189
-
190
- it('7b. E7 旁支标记统一:渲染行 `[旁支 N entries]`(N = 该 forkPoint 下旁支子树 entry 数)', () => {
191
- // branches: U → 3 entries 旁支子树(E7/D3:outline 与 export 两条路径共用此形态)
192
- const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
193
- const userEntry = uEntry('U', 'hi')
194
- const turns = [turn(0, [userEntry], { userEntry })]
195
-
196
- const result = renderOutline(turns, tree, { allBranches: true })
197
- expect(result.lines).toEqual(['T000 · hi · [旁支 3 entries]'])
198
- // branchSize 缺失(allBranches:false)不标旁支
199
- const noBranch = renderOutline(turns, tree, { allBranches: false })
200
- expect(noBranch.lines).toEqual(['T000 · hi'])
201
- })
202
-
203
- it('8. 多 turn outline:tokenEstimate <= 1500 + assistantBrief/toolSummary 非空 + stats 完整(v2 O1)', () => {
204
- // v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 行含结论与工具聚合,
205
- // 阈值 1500(design §3.3 D4)。3 turn 合成数据,每 turn 带 toolCall + toolResult
206
- const turns = [0, 1, 2].map((i) =>
207
- turn(
208
- i,
209
- [
210
- uEntry(`U${i}`, `问题 ${i}`),
211
- aEntry(`A${i}`, `结论 ${i}`, [{ name: 'bash' }, { name: 'read' }]),
212
- tEntry(`T${i}`, 'output'),
213
- ],
214
- { userEntry: uEntry(`U${i}`, `问题 ${i}`) },
215
- ),
216
- )
217
- const result = renderOutline(turns, emptyTree(), { budget: 2000 })
218
-
219
- expect(result.turns.length).toBe(3)
220
- expect(result.tokenEstimate).toBeLessThanOrEqual(1500)
221
- // v2 O1 验证:assistant 结论行存在(非空)+ toolSummary 显示工具(修 v1 恒空 bug)
222
- expect(result.turns.some((b) => b.assistantBrief !== '')).toBe(true)
223
- expect(result.turns.some((b) => b.toolSummary !== '')).toBe(true)
224
- // stats 完整性
225
- expect(result.stats.totalTurns).toBe(3)
226
- // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过)
227
- expect(result.stats.totalEntries).toBeGreaterThan(0)
228
- })
229
-
230
- // ---- D9② 占位行折叠:纯记录条目 turn 不产 outline 行、不占轮次 ----
231
-
232
- function recEntry(id: string, type: string): Entry {
233
- // pi 1.0 纯记录条目形态(usage/session_info/model_change 等,无 message 字段)
234
- return { type, id, parentId: null }
235
- }
236
-
237
- function sysEntry(id: string): Entry {
238
- // pi 1.0 system 消息(role=system,工具集变更高频追加)——非对话轴,随折叠
239
- return { type: 'message', id, parentId: null, message: { role: 'system', content: 'sys' } }
240
- }
241
-
242
- it('9. D9② 纯记录条目 turn 折叠:不产行、totalTurns 只数对话轮,对话 turn 的 T 序号保留原 index', () => {
243
- const turns = [
244
- turn(0, [recEntry('MC', 'model_change'), recEntry('TL', 'thinking_level_change')]),
245
- turn(1, [uEntry('U1', 'first question')], { userEntry: uEntry('U1', 'first question') }),
246
- turn(2, [recEntry('US', 'usage')]),
247
- turn(3, [uEntry('U2', 'second question')], { userEntry: uEntry('U2', 'second question') }),
248
- ]
249
- const result = renderOutline(turns, emptyTree(), { budget: 2000 })
250
-
251
- // 记录 turn(0/2)折叠:行只剩两个对话 turn
252
- expect(result.turns).toHaveLength(2)
253
- expect(result.lines).toHaveLength(2)
254
- expect(result.stats.totalTurns).toBe(2)
255
- // T 序号 = 原 turn.index(expand/detail 的入口地址稳定,折叠不重编号)
256
- expect(result.turns.map((b) => b.index)).toEqual([1, 3])
257
- // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据):2+1+1+1 = 5
258
- expect(result.stats.totalEntries).toBe(5)
259
- })
260
-
261
- it('10. D9② system 消息与 custom 审计条目同折叠;custom_message 参与对话流保留', () => {
262
- const turns = [
263
- turn(0, [sysEntry('S1'), recEntry('C1', 'custom')]),
264
- turn(1, [uEntry('U1', 'q')], { userEntry: uEntry('U1', 'q') }),
265
- ]
266
- const result = renderOutline(turns, emptyTree(), { budget: 2000 })
267
- expect(result.turns).toHaveLength(1)
268
- expect(result.stats.totalTurns).toBe(1)
269
-
270
- const cm: Entry = { type: 'custom_message', id: 'CM', parentId: null, customType: 'plan-context', content: 'prompt' }
271
- const withCm = renderOutline([turn(0, [cm]), turn(1, [uEntry('U1', 'q')], { userEntry: uEntry('U1', 'q') })], emptyTree(), { budget: 2000 })
272
- expect(withCm.turns).toHaveLength(2)
273
- })
274
-
275
- it('11. D9② entry 粒度同谓词:纯记录条目不产行,entryIdx 只数对话条目', () => {
276
- const turns = [
277
- turn(0, [recEntry('MC', 'model_change'), uEntry('U0', 'a')], { userEntry: uEntry('U0', 'a') }),
278
- turn(1, [recEntry('US', 'usage')]),
279
- ]
280
- const result = renderOutline(turns, emptyTree(), { granularity: 'entry', budget: 2000 })
281
- expect(result.turns).toHaveLength(1)
282
- expect(result.turns[0]?.userBrief).toBe('a')
283
- })
284
-
285
- it('12. D9② branch_summary 归纯记录折叠:前置 turn 成 turn 不产行(pi 1.0 纯记录类)', () => {
286
- // pi 1.0 dist branchWithSummary 落盘形态:{type:'branch_summary', id, parentId, fromId,
287
- // summary, details, usage, fromHook},无 message 字段。归类 = 纯记录(分叉操作痕迹,
288
- // 非当前路径对话内容;taiji 无消息编辑入口不产生该形态)
289
- const bs: Entry = { type: 'branch_summary', id: 'BS', parentId: null, summary: 'original path digest' }
290
- const turns = [
291
- turn(0, [bs]),
292
- turn(1, [uEntry('U1', 'new branch question')], { userEntry: uEntry('U1', 'new branch question') }),
293
- ]
294
- const result = renderOutline(turns, emptyTree(), { budget: 2000 })
295
- // 折叠:前置 turn 不产行,outline 只剩新路径对话 turn
296
- expect(result.turns).toHaveLength(1)
297
- expect(result.lines).toHaveLength(1)
298
- expect(result.stats.totalTurns).toBe(1)
299
- // T 序号保留原 turn.index(expand/detail 入口地址稳定)
300
- expect(result.turns.map((b) => b.index)).toEqual([1])
301
- // 字节/条目统计保持全量口径:BS + U1 = 2
302
- expect(result.stats.totalEntries).toBe(2)
303
- })
304
- })
305
-
306
- describe('renderExpand', () => {
307
- it('单轮展开:header 含 entry 数 + 每个 entry brief', () => {
308
- const t = turn(
309
- 0,
310
- [uEntry('U', 'hello world'), aEntry('A', 'ok'), tEntry('T', 'result text')],
311
- { userEntry: uEntry('U', 'hello world'), startTime: '2026-05-28T03:17:12.844Z' },
312
- )
313
- const out = renderExpand(t)
314
-
315
- expect(out.turn).toContain('3 entries')
316
- expect(out.turn).toContain('03:17')
317
- expect(out.entries).toHaveLength(3)
318
- expect(out.entries[0]).toMatchObject({ index: 0, type: 'message', role: 'user' })
319
- expect(out.entries[0].brief).toBe('hello world')
320
- expect(out.entries[1].role).toBe('assistant')
321
- expect(out.entries[2].role).toBe('toolResult')
322
- // toolResult 的 omittedBytes = content 字节
323
- expect(out.entries[2].omittedBytes).toBe(Buffer.byteLength('result text', 'utf8'))
324
- })
325
-
326
- it('v2 O2:toolResult 类型化摘要(bash: <cmd> (N行),toolCallId 关联取 args)', () => {
327
- // 构造带 toolCallId 关联的 turn:assistant 调 bash,toolResult 靠 toolCallId 关联回去取 command
328
- const tcId = 'call_bash1'
329
- const t = turn(
330
- 0,
331
- [
332
- uEntry('U', '查一下文件'),
333
- aEntry('A', '好的', [{ name: 'bash', id: tcId, arguments: { command: 'ls -la src/' } }]),
334
- tEntry('T', 'file1\nfile2\nfile3', { toolCallId: tcId, toolName: 'bash' }),
335
- ],
336
- { userEntry: uEntry('U', '查一下文件') },
337
- )
338
- const out = renderExpand(t)
339
- const trBrief = out.entries.find((e) => e.role === 'toolResult')!
340
- // 类型化摘要:bash: <cmd> (N行),不是结果文本前 100 字(file1/file2...)
341
- expect(trBrief.brief).toContain('bash: ls -la src/')
342
- expect(trBrief.brief).toContain('3行')
343
- expect(trBrief.brief).not.toContain('file1')
344
- })
345
-
346
- it('v2 O2:read/edit/write 等工具的类型化摘要', () => {
347
- const t = turn(
348
- 0,
349
- [
350
- uEntry('U', '改代码'),
351
- aEntry('A1', '读文件', [{ name: 'read', id: 'r1', arguments: { path: '/a/b/src/index.ts' } }]),
352
- tEntry('TR1', 'content', { toolCallId: 'r1', toolName: 'read' }),
353
- aEntry('A2', '编辑', [{ name: 'edit', id: 'e1', arguments: { path: '/a/b/foo.ts', edits: [{}, {}] } }]),
354
- tEntry('TR2', 'ok', { toolCallId: 'e1', toolName: 'edit' }),
355
- aEntry('A3', '写', [{ name: 'write', id: 'w1', arguments: { path: '/a/b/out.txt', content: 'x'.repeat(2048) } }]),
356
- tEntry('TR3', 'ok', { toolCallId: 'w1', toolName: 'write' }),
357
- ],
358
- { userEntry: uEntry('U', '改代码') },
359
- )
360
- const out = renderExpand(t)
361
- const briefs = out.entries.filter((e) => e.role === 'toolResult').map((e) => e.brief)
362
- // read: read: index.ts (NKB)(结果 KB)
363
- expect(briefs[0]).toMatch(/^read: index\.ts \(\d+KB\)$/) // 点号转义
364
- // edit: edit: foo.ts (2 blocks)(参数 blocks)
365
- expect(briefs[1]).toBe('edit: foo.ts (2 blocks)')
366
- // write: write: out.txt (NKB)(参数 content KB)
367
- expect(briefs[2]).toMatch(/^write: out\.txt \(\d+KB\)$/) // 修正:out.txt
368
- })
369
- })
370
-
371
- describe('renderDetail', () => {
372
- it('默认 toolResult 摘要态(O3)+ thinking 剥离;includeToolResult/includeThinking 取回', () => {
373
- const t = turn(0, [
374
- uEntry('U', 'q'),
375
- aEntry('A', 'visible', undefined, 'hidden-thinking'),
376
- tEntry('T', 'tool-output'),
377
- ], { userEntry: uEntry('U', 'q') })
378
-
379
- // v2 O3:默认不再 continue 跳过 toolResult,条目数不减少(toolResult 变摘要态)
380
- const defaultOut = renderDetail([t])
381
- expect(defaultOut).toHaveLength(3)
382
- expect(defaultOut.some((e) => e.type === 'toolResultSummary')).toBe(true)
383
- // thinking 块被剥离(assistant 副本,无 thinking)
384
- const entries = defaultOut.filter((e): e is Entry => e.type !== 'toolResultSummary')
385
- const assistantDefault = entries.find((e) => e.message?.role === 'assistant')!
386
- expect(extractTextForTest(assistantDefault.message!.content)).toBe('visible')
387
-
388
- // includeToolResult:toolResult 原文 entry 回来(非摘要态)
389
- const withTool = renderDetail([t], { includeToolResult: true })
390
- expect(withTool).toHaveLength(3)
391
- expect(
392
- withTool.some(
393
- (e): e is Entry => e.type !== 'toolResultSummary' && e.message?.role === 'toolResult',
394
- ),
395
- ).toBe(true)
396
-
397
- // includeThinking:thinking 块回来
398
- const withThinking = renderDetail([t], { includeThinking: true })
399
- const entriesT = withThinking.filter((e): e is Entry => e.type !== 'toolResultSummary')
400
- const assistantThinking = entriesT.find((e) => e.message?.role === 'assistant')!
401
- expect(extractTextForTest(assistantThinking.message!.content)).toBe('visible')
402
- expect(extractThinkingForTest(assistantThinking.message!.content)).toBe('hidden-thinking')
403
- })
404
-
405
- it('v2 O3:renderDetail 默认摘要态 + includeToolResult 全文', () => {
406
- const tcId = 'call_r1'
407
- const t = turn(
408
- 0,
409
- [
410
- uEntry('U', 'q'),
411
- aEntry('A', 'r', [{ name: 'read', id: tcId, arguments: { path: '/x/y/f.ts' } }]),
412
- tEntry('T', 'line1\nline2\nline3\nline4', { toolCallId: tcId, toolName: 'read' }),
413
- ],
414
- { userEntry: uEntry('U', 'q') },
415
- )
416
-
417
- // 默认摘要态
418
- const def = renderDetail([t])
419
- expect(def).toHaveLength(3)
420
- const summary = def.find(
421
- (e): e is ToolResultSummaryEntry => e.type === 'toolResultSummary',
422
- )!
423
- expect(summary).toBeDefined()
424
- expect(summary.summary).toMatch(/^read: f\.ts \(\d+KB\)$/)
425
- expect(summary.totalLines).toBe(4)
426
- expect(summary.headLines).toContain('line1')
427
-
428
- // includeToolResult 全文:返回原 Entry
429
- const full = renderDetail([t], { includeToolResult: true })
430
- expect(full).toHaveLength(3)
431
- expect(
432
- full.some(
433
- (e): e is Entry => e.type !== 'toolResultSummary' && e.message?.role === 'toolResult',
434
- ),
435
- ).toBe(true)
436
- })
437
-
438
- it('v2 S1:空 content 的 toolResult totalLines=0(与 formatToolResultSummary 口径一致)', () => {
439
- // 同一空 toolResult:summary 显示 "(0行)",totalLines 必须也是 0(非 ''.split('\n') 的 1)
440
- const tcId = 'call_bash1'
441
- const t = turn(
442
- 0,
443
- [
444
- uEntry('U', 'q'),
445
- aEntry('A', 'r', [{ name: 'bash', id: tcId, arguments: { command: 'ls' } }]),
446
- tEntry('T', '', { toolCallId: tcId, toolName: 'bash' }), // 空 content
447
- ],
448
- { userEntry: uEntry('U', 'q') },
449
- )
450
- const def = renderDetail([t])
451
- const summary = def.find(
452
- (e): e is ToolResultSummaryEntry => e.type === 'toolResultSummary',
453
- )!
454
- expect(summary).toBeDefined()
455
- expect(summary.totalLines).toBe(0) // 空结果 = 0 行(非 1)
456
- expect(summary.headLines).toBe('') // 空结果无头行
457
- // 对照:summary 文案也不含行数(formatToolResultSummary 空结果不 append (N行))
458
- expect(summary.summary).not.toContain('行)')
459
- })
460
- })
461
-
462
- // 测试内联的类型检查镜像(验证 renderDetail 剥离效果,不依赖 render 内部导出)
463
- function extractTextForTest(content: unknown): string {
464
- if (typeof content === 'string') return content
465
- if (Array.isArray(content)) {
466
- return content
467
- .filter((b) => (b as { type?: string }).type !== 'thinking')
468
- .map((b) => ((b as { text?: string }).text) ?? '')
469
- .join('')
470
- }
471
- return ''
472
- }
473
- function extractThinkingForTest(content: unknown): string {
474
- if (Array.isArray(content)) {
475
- return content
476
- .filter((b) => (b as { type?: string }).type === 'thinking')
477
- .map((b) => ((b as { thinking?: string }).thinking) ?? '')
478
- .join('')
479
- }
480
- return ''
481
- }