@zhushanwen/pi-session-reader 0.3.0 → 0.4.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.
@@ -151,6 +151,33 @@ describe('parseRunSnapshot', () => {
151
151
  expect(overview!.steps[1].contentPreview).toBe('OK')
152
152
  })
153
153
 
154
+ it('TC-w5-parse-new-status-unknown:NEW call.status 非 done/running/pending(如 failed)→ step.status=pending', () => {
155
+ // call.status 出现三态外的值(如上游异常态)时收窄为 pending,其余字段照常提取
156
+ const fixture = {
157
+ ...NEW_SNAPSHOT_FIXTURE,
158
+ state: {
159
+ ...NEW_SNAPSHOT_FIXTURE.state,
160
+ calls: [
161
+ {
162
+ id: 3,
163
+ opts: { description: 'step-3' },
164
+ status: 'failed',
165
+ result: { content: 'partial', durationMs: 9 },
166
+ },
167
+ ],
168
+ },
169
+ }
170
+ const overview = parseRunSnapshot(fixture, 'wf-status-runid', '/abs/wf.jsonl')
171
+ expect(overview).not.toBeNull()
172
+ expect(overview!.steps).toHaveLength(1)
173
+ const step = overview!.steps[0]
174
+ expect(step.index).toBe(3)
175
+ expect(step.status).toBe('pending')
176
+ expect(step.description).toBe('step-3')
177
+ expect(step.contentPreview).toBe('partial')
178
+ expect(step.durationMs).toBe(9)
179
+ })
180
+
154
181
  it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
155
182
  const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
156
183
  expect(overview).not.toBeNull()
@@ -199,6 +226,37 @@ describe('extractCallSessionFiles', () => {
199
226
  const files = extractCallSessionFiles(NEW_SNAPSHOT_FIXTURE)
200
227
  expect(files).toEqual(['/abs/session.jsonl'])
201
228
  })
229
+
230
+ it('OLD 快照(无 v,callCache)从 value 顶层与 value.result 提取 sessionFile', () => {
231
+ const files = extractCallSessionFiles({
232
+ callCache: [
233
+ { key: 0, value: { sessionFile: '/abs/old-call0.jsonl' } },
234
+ { key: 1, value: { result: { sessionFile: '/abs/old-call1.jsonl' } } },
235
+ ],
236
+ })
237
+ expect(files).toEqual(['/abs/old-call0.jsonl', '/abs/old-call1.jsonl'])
238
+ })
239
+
240
+ it('OLD 快照 value 无 sessionFile / value 非对象 → 跳过该 call,不产出路径', () => {
241
+ // 对齐 OLD_SNAPSHOT_FIXTURE 形状(旧 pi 不持久化 sessionFile)+ value 非对象脏数据回退 call 本身
242
+ const files = extractCallSessionFiles({
243
+ callCache: [
244
+ { key: 7, value: { content: '', usage: { input: 0 } } },
245
+ { key: 8, value: 42 },
246
+ { key: 9, sessionFile: '/abs/bare-call.jsonl' },
247
+ ],
248
+ })
249
+ expect(files).toEqual(['/abs/bare-call.jsonl'])
250
+ })
251
+
252
+ it('快照非对象 / calls 容器缺失 / call 元素非对象 → 空数组', () => {
253
+ expect(extractCallSessionFiles(null)).toEqual([])
254
+ expect(extractCallSessionFiles('wf-run-v1')).toEqual([])
255
+ expect(extractCallSessionFiles({ v: 'wf-run-v1', state: null })).toEqual([])
256
+ expect(extractCallSessionFiles({ v: 'wf-run-v1', state: {} })).toEqual([])
257
+ expect(extractCallSessionFiles({ callCache: 'not-an-array' })).toEqual([])
258
+ expect(extractCallSessionFiles({ callCache: [null, 7, 'x'] })).toEqual([])
259
+ })
202
260
  })
203
261
 
204
262
  // ============================================================
@@ -282,7 +340,7 @@ describe('renderWorkflowOverview', () => {
282
340
  describe('readRunSnapshot', () => {
283
341
  let dir: string
284
342
  afterEach(async () => {
285
- if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {})
343
+ if (dir) await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }).catch(() => {})
286
344
  })
287
345
 
288
346
  it('TC-w5-read-tail-fallback:末行半截 JSON 回退倒数第二行完整快照', async () => {
@@ -134,24 +134,12 @@ function resolveParentSessionId(
134
134
  return null
135
135
  }
136
136
 
137
- /**
138
- * 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
139
- *
140
- * - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
141
- * - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
142
- * - fileStats 原样存入 index,供 cleanedUp 判断
143
- *
144
- * 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
145
- * 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
146
- */
147
- export function buildFamilyIndex(
137
+ /** 阶段 1:headers → byId(SessionRef 组装,mtime/size 从 fileStats 取,取不到为 0)。 */
138
+ function buildById(
148
139
  headers: Entry[],
149
- subagentIdentities: Entry[],
150
140
  fileStats: Map<string, { mtime: number; size: number }>,
151
- ): FamilyIndex {
141
+ ): Map<string, SessionRef> {
152
142
  const byId = new Map<string, SessionRef>()
153
-
154
- // 1. headers → byId
155
143
  for (const h of headers) {
156
144
  const stat = fileStats.get(h.id)
157
145
  const ref: SessionRef = {
@@ -164,8 +152,17 @@ export function buildFamilyIndex(
164
152
  if (h.parentSession) ref.parentSession = h.parentSession
165
153
  byId.set(h.id, ref)
166
154
  }
155
+ return byId
156
+ }
167
157
 
168
- // 2. childrenOf:parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId
158
+ /**
159
+ * 阶段 2:childrenOf——parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId。
160
+ * 反查不到父 → 无法建反查关系,跳过(不报错,design §2 容错)。
161
+ */
162
+ function buildChildrenOf(
163
+ headers: Entry[],
164
+ byId: Map<string, SessionRef>,
165
+ ): Map<string, SessionRef[]> {
169
166
  const childrenOf = new Map<string, SessionRef[]>()
170
167
  for (const h of headers) {
171
168
  if (!h.parentSession) continue
@@ -177,8 +174,17 @@ export function buildFamilyIndex(
177
174
  list.push(childRef)
178
175
  childrenOf.set(parentSid, list)
179
176
  }
177
+ return childrenOf
178
+ }
180
179
 
181
- // 3. subagentIdentities → subagentsByRoot
180
+ /**
181
+ * 阶段 3:subagentIdentities → subagentsByRoot。
182
+ * 坏数据(缺 rootSessionId/slug)跳过,不报错(design §2 容错)。
183
+ */
184
+ function buildSubagentsByRoot(
185
+ subagentIdentities: Entry[],
186
+ fileStats: Map<string, { mtime: number; size: number }>,
187
+ ): Map<string, SubagentRef[]> {
182
188
  const subagentsByRoot = new Map<string, SubagentRef[]>()
183
189
  for (const ident of subagentIdentities) {
184
190
  if (!isSubagentIdentityData(ident.data)) continue // 坏数据(缺 rootSessionId/slug)跳过
@@ -207,7 +213,27 @@ export function buildFamilyIndex(
207
213
  list.push(ref)
208
214
  subagentsByRoot.set(ident.data.rootSessionId, list)
209
215
  }
216
+ return subagentsByRoot
217
+ }
210
218
 
219
+ /**
220
+ * 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
221
+ *
222
+ * - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
223
+ * - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
224
+ * - fileStats 原样存入 index,供 cleanedUp 判断
225
+ *
226
+ * 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
227
+ * 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
228
+ */
229
+ export function buildFamilyIndex(
230
+ headers: Entry[],
231
+ subagentIdentities: Entry[],
232
+ fileStats: Map<string, { mtime: number; size: number }>,
233
+ ): FamilyIndex {
234
+ const byId = buildById(headers, fileStats)
235
+ const childrenOf = buildChildrenOf(headers, byId)
236
+ const subagentsByRoot = buildSubagentsByRoot(subagentIdentities, fileStats)
211
237
  return { byId, childrenOf, subagentsByRoot, fileStats }
212
238
  }
213
239
 
@@ -51,6 +51,31 @@ function isMessageRole(v: unknown): v is 'user' | 'assistant' | 'toolResult' {
51
51
  return v === 'user' || v === 'assistant' || v === 'toolResult'
52
52
  }
53
53
 
54
+ /**
55
+ * 归一化 message 字段(阶段 helper:role 经值守卫收窄,缺/非法 role 时
56
+ * 返回 undefined,调用方丢弃 message 字段——接口 role 必填)。
57
+ */
58
+ function parseEntryMessage(raw: unknown): Entry['message'] | undefined {
59
+ if (raw === null || typeof raw !== 'object') return undefined
60
+ const m = raw as Record<string, unknown>
61
+ if (!isMessageRole(m.role)) return undefined
62
+ const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
63
+ if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
64
+ // toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
65
+ if (typeof m.toolName === 'string') message.toolName = m.toolName
66
+ if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
67
+ return message
68
+ }
69
+
70
+ /** 归一化剩余可选字段(阶段 helper:customType/data/parentSession/cwd/summary)。 */
71
+ function assignOptionalFields(obj: Record<string, unknown>, entry: Entry): void {
72
+ if (typeof obj.customType === 'string') entry.customType = obj.customType
73
+ if (obj.data !== undefined) entry.data = obj.data
74
+ if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
75
+ if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
76
+ if (obj.summary !== undefined) entry.summary = obj.summary
77
+ }
78
+
54
79
  /**
55
80
  * 把单个已 JSON.parse 成功的原始对象归一化为 Entry。
56
81
  * 缺必填结构字段(type/id)返回 undefined,调用方计为坏行(skippedLines++)。
@@ -74,24 +99,10 @@ function toEntry(raw: unknown): Entry | undefined {
74
99
  }
75
100
  if (typeof obj.timestamp === 'string') entry.timestamp = obj.timestamp
76
101
 
77
- // message:role 经值守卫收窄,缺/非法 role 时丢弃 message 字段(接口 role 必填)
78
- if (obj.message !== null && typeof obj.message === 'object') {
79
- const m = obj.message as Record<string, unknown>
80
- if (isMessageRole(m.role)) {
81
- const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
82
- if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
83
- // toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
84
- if (typeof m.toolName === 'string') message.toolName = m.toolName
85
- if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
86
- entry.message = message
87
- }
88
- }
102
+ const message = parseEntryMessage(obj.message)
103
+ if (message !== undefined) entry.message = message
89
104
 
90
- if (typeof obj.customType === 'string') entry.customType = obj.customType
91
- if (obj.data !== undefined) entry.data = obj.data
92
- if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
93
- if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
94
- if (obj.summary !== undefined) entry.summary = obj.summary
105
+ assignOptionalFields(obj, entry)
95
106
 
96
107
  return entry
97
108
  }
@@ -330,6 +330,61 @@ function leafParsedBytes(turns: Turn[]): number {
330
330
  return parsedBytes
331
331
  }
332
332
 
333
+ /** allBranches:对每个 brief 标注所在 turn 的 forkPoint(找到才标 branch)。 */
334
+ function applyBranchLabels(briefs: TurnBrief[], turns: Turn[], tree: TreeView): void {
335
+ for (const b of briefs) {
336
+ const fp = findBranchForkPoint(turns[b.index], tree)
337
+ if (fp !== undefined) b.branch = fp
338
+ }
339
+ }
340
+
341
+ /**
342
+ * 降级序逐行渲染(design §3.5 算法 1 step3):level 0 全有;超预算降到 level 1
343
+ * 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。
344
+ * 直接改写 brief 的 assistantBrief/toolSummary 字段,返回行缓存。
345
+ */
346
+ function renderDegradingLines(
347
+ briefs: TurnBrief[],
348
+ tree: TreeView,
349
+ perTurnCharBudget: number,
350
+ ): string[] {
351
+ const lineCache: string[] = []
352
+ for (const b of briefs) {
353
+ const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
354
+ let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
355
+ if (line.length > perTurnCharBudget) {
356
+ // 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
357
+ b.assistantBrief = ''
358
+ line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
359
+ if (line.length > perTurnCharBudget) {
360
+ b.toolSummary = ''
361
+ line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
362
+ }
363
+ }
364
+ lineCache.push(line)
365
+ }
366
+ return lineCache
367
+ }
368
+
369
+ /** 总预算截断(从尾部丢弃):就地截短 lines 与 briefs,返回最终 totalChars 与 truncated。 */
370
+ function truncateToTotalBudget(
371
+ briefs: TurnBrief[],
372
+ lines: string[],
373
+ budget: number,
374
+ ): { totalChars: number; truncated: number | undefined } {
375
+ let totalChars = lines.reduce((s, l) => s + l.length, 0)
376
+ if (totalChars / CHARS_PER_TOKEN <= budget) return { totalChars, truncated: undefined }
377
+ let kept = lines.length
378
+ while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
379
+ kept--
380
+ totalChars -= lines[kept].length
381
+ }
382
+ const truncated = lines.length - kept
383
+ lines.length = kept
384
+ briefs.length = kept
385
+ return { totalChars, truncated }
386
+ }
387
+
333
388
  export function renderOutline(
334
389
  turns: Turn[],
335
390
  tree: TreeView,
@@ -365,46 +420,16 @@ export function renderOutline(
365
420
  const briefs: TurnBrief[] = turns.map(computeBrief)
366
421
 
367
422
  // allBranches:标注 forkPoint
368
- if (allBranches) {
369
- for (const b of briefs) {
370
- const fp = findBranchForkPoint(turns[b.index], tree)
371
- if (fp !== undefined) b.branch = fp
372
- }
373
- }
423
+ if (allBranches) applyBranchLabels(briefs, turns, tree)
374
424
 
375
425
  // 2. perTurnBudget(token → chars×4)
376
426
  const perTurnCharBudget = (budget / turns.length) * CHARS_PER_TOKEN
377
427
 
378
- // 3. 降级序:level 0 全有;超预算降到 level 1 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。design §3.5 算法 1 step3
379
- const lineCache: string[] = []
380
- for (const b of briefs) {
381
- const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
382
- let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
383
- if (line.length > perTurnCharBudget) {
384
- // 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
385
- b.assistantBrief = ''
386
- line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
387
- if (line.length > perTurnCharBudget) {
388
- b.toolSummary = ''
389
- line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
390
- }
391
- }
392
- lineCache.push(line)
393
- }
428
+ // 3. 降级序渲染:level 0 全有 → level 1 砍 assistantBrief → level 2 骨架
429
+ const lineCache = renderDegradingLines(briefs, tree, perTurnCharBudget)
394
430
 
395
431
  // 4. 总预算截断(从尾部丢弃)
396
- let totalChars = lineCache.reduce((s, l) => s + l.length, 0)
397
- let truncated: number | undefined
398
- if (totalChars / CHARS_PER_TOKEN > budget) {
399
- let kept = lineCache.length
400
- while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
401
- kept--
402
- totalChars -= lineCache[kept].length
403
- }
404
- truncated = lineCache.length - kept
405
- lineCache.length = kept
406
- briefs.length = kept
407
- }
432
+ const { totalChars, truncated } = truncateToTotalBudget(briefs, lineCache, budget)
408
433
 
409
434
  const tokenEstimate = Math.ceil(totalChars / CHARS_PER_TOKEN)
410
435
  return { turns: briefs, stats, tokenEstimate, truncated }
@@ -102,11 +102,111 @@ function numArg(args: Record<string, unknown>, k: string): number | undefined {
102
102
  return typeof v === 'number' ? v : undefined
103
103
  }
104
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // 各工具参数摘要 formatter(design §3.3 D1 表的表驱动形态,每个 case 体一个函数)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** 单工具参数摘要 formatter:输入归一化后的 arguments,输出摘要串(参数缺失时优雅降级)。 */
110
+ type ArgsFormatter = (args: Record<string, unknown>) => string
111
+
112
+ function formatBashArgs(args: Record<string, unknown>): string {
113
+ const cmd = strArg(args, 'command')
114
+ return cmd !== undefined ? `bash: ${truncate(cmd, BASH_CMD_MAX_CHARS)}` : 'bash'
115
+ }
116
+
117
+ function formatReadArgs(args: Record<string, unknown>): string {
118
+ const p = strArg(args, 'path')
119
+ return p !== undefined ? `read: ${basename(p)}` : 'read'
120
+ }
121
+
122
+ function formatEditArgs(args: Record<string, unknown>): string {
123
+ const p = strArg(args, 'path')
124
+ const edits = args.edits
125
+ const blocks = Array.isArray(edits) ? edits.length : undefined
126
+ const head = p !== undefined ? `edit: ${basename(p)}` : 'edit'
127
+ return blocks !== undefined ? `${head} (${blocks} blocks)` : head
128
+ }
129
+
130
+ function formatWriteArgs(args: Record<string, unknown>): string {
131
+ const p = strArg(args, 'path')
132
+ const c = strArg(args, 'content')
133
+ const head = p !== undefined ? `write: ${basename(p)}` : 'write'
134
+ if (c === undefined) return head
135
+ // utf8 字节转 KB 取整;小文件至少 1KB(0KB 无信息量)
136
+ const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / BYTES_PER_KB))
137
+ return `${head} (${kb}KB)`
138
+ }
139
+
140
+ function formatSubagentArgs(args: Record<string, unknown>): string {
141
+ const task = strArg(args, 'task')
142
+ return task !== undefined ? `subagent: ${truncate(task, SUBAGENT_TASK_MAX_CHARS)}` : 'subagent'
143
+ }
144
+
145
+ function formatHeadArgs(args: Record<string, unknown>): string {
146
+ const p = strArg(args, 'path')
147
+ // limit 可能是 number 或 string(实测 number,防御 string)
148
+ const lim: number | string | undefined = numArg(args, 'limit') ?? strArg(args, 'limit')
149
+ const head = p !== undefined ? `head: ${basename(p)}` : 'head'
150
+ return lim !== undefined ? `${head} (${lim})` : head
151
+ }
152
+
153
+ function formatTodoArgs(args: Record<string, unknown>): string {
154
+ const action = strArg(args, 'action')
155
+ if (action === undefined) return 'todo'
156
+ // id 可能是 number(todo 列表 id)或 string
157
+ const id = args.id
158
+ const idStr =
159
+ typeof id === 'string' ? id : typeof id === 'number' ? String(id) : undefined
160
+ return idStr !== undefined ? `todo: ${action}(${idStr})` : `todo: ${action}`
161
+ }
162
+
163
+ function formatCwGateArgs(args: Record<string, unknown>): string {
164
+ const phase = args.phase
165
+ return phase !== undefined ? `cw-gate: phase=${String(phase)}` : 'cw-gate'
166
+ }
167
+
168
+ function formatCwInitArgs(args: Record<string, unknown>): string {
169
+ const slug = strArg(args, 'slug')
170
+ return slug !== undefined ? `cw-init: ${slug}` : 'cw-init'
171
+ }
172
+
105
173
  /**
106
- * 按工具类型把 ToolCallInfo 映射成参数摘要串(design §3.3 D1 表)。
174
+ * 未知工具 fallback(原 switch default):arguments 非空 → name: <json 前50>;
175
+ * 空对象 → 仅 name({} 无信息量)。
176
+ */
177
+ function formatUnknownTool(name: string, args: Record<string, unknown>): string {
178
+ if (Object.keys(args).length === 0) return name
179
+ try {
180
+ return `${name}: ${truncate(JSON.stringify(args), ARGS_JSON_MAX_CHARS)}`
181
+ } catch {
182
+ return name
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 工具名 → 参数摘要 formatter 表(design §3.3 D1 表;phase-start 无参数维度恒返常量)。
188
+ * 用 Map 而非普通对象:name 是外部输入(pi session JSONL 的 toolName),普通对象查表会
189
+ * 命中 Object.prototype 上的键(如 "constructor"),导致把非函数当 formatter 调用;Map 只查自身键。
190
+ */
191
+ const TOOL_SUMMARY_FORMATTERS: ReadonlyMap<string, ArgsFormatter> = new Map([
192
+ ['bash', formatBashArgs],
193
+ ['read', formatReadArgs],
194
+ ['edit', formatEditArgs],
195
+ ['write', formatWriteArgs],
196
+ ['subagent', formatSubagentArgs],
197
+ ['head', formatHeadArgs],
198
+ ['todo', formatTodoArgs],
199
+ ['coding-workflow-gate', formatCwGateArgs],
200
+ ['coding-workflow-init', formatCwInitArgs],
201
+ ['coding-workflow-phase-start', () => 'cw-phase-start'],
202
+ ])
203
+
204
+ /**
205
+ * 按工具类型把 ToolCallInfo 映射成参数摘要串(design §3.3 D1 表,表驱动分发)。
107
206
  *
108
- * 参数缺失时优雅降级(只输出工具名或省略对应维度)。未知工具 fallback 带 arguments JSON
109
- * 前 50 字;arguments 为空对象时省略 JSON(`{}` 无信息量)。
207
+ * 参数缺失时优雅降级(只输出工具名或省略对应维度)。未知工具(表落空)走
208
+ * formatUnknownTool:带 arguments JSON 前 50 字;arguments 为空对象时省略 JSON
209
+ * (`{}` 无信息量)。
110
210
  *
111
211
  * 单位口径:edit 的 blocks 是参数维度(edits 数组长度),write 的 KB 是参数维度(content 字节),
112
212
  * 这两者已含参数规模;O2/O3 的 toolResult 摘要在此基础再加结果规模时,bash/read 单独 append
@@ -114,70 +214,6 @@ function numArg(args: Record<string, unknown>, k: string): number | undefined {
114
214
  */
115
215
  export function formatToolCallSummary(tc: ToolCallInfo): string {
116
216
  const { name, arguments: args } = tc
117
-
118
- switch (name) {
119
- case 'bash': {
120
- const cmd = strArg(args, 'command')
121
- return cmd !== undefined ? `bash: ${truncate(cmd, BASH_CMD_MAX_CHARS)}` : 'bash'
122
- }
123
- case 'read': {
124
- const p = strArg(args, 'path')
125
- return p !== undefined ? `read: ${basename(p)}` : 'read'
126
- }
127
- case 'edit': {
128
- const p = strArg(args, 'path')
129
- const edits = args.edits
130
- const blocks = Array.isArray(edits) ? edits.length : undefined
131
- const head = p !== undefined ? `edit: ${basename(p)}` : 'edit'
132
- return blocks !== undefined ? `${head} (${blocks} blocks)` : head
133
- }
134
- case 'write': {
135
- const p = strArg(args, 'path')
136
- const c = strArg(args, 'content')
137
- const head = p !== undefined ? `write: ${basename(p)}` : 'write'
138
- if (c === undefined) return head
139
- // utf8 字节转 KB 取整;小文件至少 1KB(0KB 无信息量)
140
- const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / BYTES_PER_KB))
141
- return `${head} (${kb}KB)`
142
- }
143
- case 'subagent': {
144
- const task = strArg(args, 'task')
145
- return task !== undefined ? `subagent: ${truncate(task, SUBAGENT_TASK_MAX_CHARS)}` : 'subagent'
146
- }
147
- case 'head': {
148
- const p = strArg(args, 'path')
149
- // limit 可能是 number 或 string(实测 number,防御 string)
150
- const lim: number | string | undefined = numArg(args, 'limit') ?? strArg(args, 'limit')
151
- const head = p !== undefined ? `head: ${basename(p)}` : 'head'
152
- return lim !== undefined ? `${head} (${lim})` : head
153
- }
154
- case 'todo': {
155
- const action = strArg(args, 'action')
156
- if (action === undefined) return 'todo'
157
- // id 可能是 number(todo 列表 id)或 string
158
- const id = args.id
159
- const idStr =
160
- typeof id === 'string' ? id : typeof id === 'number' ? String(id) : undefined
161
- return idStr !== undefined ? `todo: ${action}(${idStr})` : `todo: ${action}`
162
- }
163
- case 'coding-workflow-gate': {
164
- const phase = args.phase
165
- return phase !== undefined ? `cw-gate: phase=${String(phase)}` : 'cw-gate'
166
- }
167
- case 'coding-workflow-init': {
168
- const slug = strArg(args, 'slug')
169
- return slug !== undefined ? `cw-init: ${slug}` : 'cw-init'
170
- }
171
- case 'coding-workflow-phase-start':
172
- return 'cw-phase-start'
173
- default: {
174
- // 未知工具:arguments 非空 → name: <json 前50>;空对象 → 仅 name({} 无信息量)
175
- if (Object.keys(args).length === 0) return name
176
- try {
177
- return `${name}: ${truncate(JSON.stringify(args), ARGS_JSON_MAX_CHARS)}`
178
- } catch {
179
- return name
180
- }
181
- }
182
- }
217
+ const formatter = TOOL_SUMMARY_FORMATTERS.get(name)
218
+ return formatter !== undefined ? formatter(args) : formatUnknownTool(name, args)
183
219
  }