@zhushanwen/pi-subagents 0.0.1

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.
Files changed (37) hide show
  1. package/agents/context-builder.md +19 -0
  2. package/agents/oracle.md +19 -0
  3. package/agents/planner.md +19 -0
  4. package/agents/researcher.md +19 -0
  5. package/agents/reviewer.md +19 -0
  6. package/agents/scout.md +19 -0
  7. package/agents/worker.md +18 -0
  8. package/index.ts +1 -0
  9. package/package.json +59 -0
  10. package/src/commands/subagents.ts +78 -0
  11. package/src/core/agent-registry.ts +222 -0
  12. package/src/core/concurrency-pool.ts +78 -0
  13. package/src/core/event-bridge.ts +199 -0
  14. package/src/core/execution-record.ts +500 -0
  15. package/src/core/model-resolver.ts +206 -0
  16. package/src/core/output-collector.ts +118 -0
  17. package/src/core/path-encoding.ts +16 -0
  18. package/src/core/session-factory.ts +365 -0
  19. package/src/core/session-runner.ts +303 -0
  20. package/src/core/turn-limiter.ts +71 -0
  21. package/src/index.ts +104 -0
  22. package/src/runtime/config/config.ts +170 -0
  23. package/src/runtime/discovery-config.ts +135 -0
  24. package/src/runtime/execution/history-store.ts +196 -0
  25. package/src/runtime/execution/notifier.ts +209 -0
  26. package/src/runtime/execution/record-store.ts +280 -0
  27. package/src/runtime/model-config-service.ts +265 -0
  28. package/src/runtime/session-file-gc.ts +70 -0
  29. package/src/runtime/subagent-service.ts +549 -0
  30. package/src/tools/subagent-tool.ts +286 -0
  31. package/src/tui/bg-notify-render.ts +139 -0
  32. package/src/tui/config-wizard.ts +253 -0
  33. package/src/tui/format-helpers.ts +37 -0
  34. package/src/tui/format.ts +332 -0
  35. package/src/tui/list-view.ts +883 -0
  36. package/src/tui/tool-render.ts +467 -0
  37. package/src/types.ts +334 -0
@@ -0,0 +1,467 @@
1
+ // src/tui/tool-render.ts
2
+ //
3
+ // 对话流 tool block 渲染。renderCall(标题行)+ renderResult(背景色 block)。
4
+ //
5
+ // 关键设计(Bug #2/#4 修复 + pi-tui-development-guide.md 三条红线):
6
+ // 1. 不设 renderShell(默认 default)。背景色/padding 归 Pi 的 contentBox = Box(1,1,bgFn),
7
+ // 它按 isPartial/isError 自动切 toolPendingBg/toolSuccessBg/toolErrorBg 三态。
8
+ // 组件 render 返回的 string[] **绝不调 theme.bg**——否则双重背景混色(坑2)。
9
+ // 2. 所有输出行经 truncLine(ANSI 安全,省略号前重应用 SGR,背景不断裂——坑2)。
10
+ // 3. 上下留白(Spacer(1) + Box paddingY=1)由 ToolExecutionComponent 负责,
11
+ // 组件不自己加 Spacer 做间隔(坑3)。
12
+ // 4. spinner 由 Date.now() 选帧 + 低频 setInterval 驱动 invalidate,不用 seed-frame(坑1 残影根因之一)。
13
+ // 5. streaming delta(text/thinking)不触发 onUpdate,仅离散边界事件触发重绘(避 viewport snap-back)。
14
+ // 6. 复用 lastComponent(P1a 优化,省 GC + 防 theme 闪烁)。
15
+
16
+ import type { Component } from "@earendil-works/pi-tui";
17
+ import { Text } from "@earendil-works/pi-tui";
18
+ import type { AgentToolResult, Theme } from "@mariozechner/pi-coding-agent";
19
+
20
+ import type { AgentEventLogEntry, SubagentToolDetails } from "../types.ts";
21
+ import {
22
+ extractAgentName,
23
+ firstLine,
24
+ formatElapsedSeconds,
25
+ formatEventLine,
26
+ formatTokens,
27
+ sanitizeLabel,
28
+ spinnerGlyph,
29
+ statusGlyph,
30
+ type ThemeLike,
31
+ truncLine,
32
+ } from "./format.ts";
33
+
34
+ // ============================================================
35
+ // 常量
36
+ // ============================================================
37
+
38
+ /** message stream 每行的缩进前缀(2 空格 + ⎿ + 空格),dim 色。 */
39
+ const STREAM_PREFIX = " ⎿ ";
40
+
41
+ /** footer 用的纯空格缩进(与 STREAM_PREFIX 等宽 4 列,但不带 ⎿)。 */
42
+ const FOOTER_PREFIX = " ";
43
+
44
+ /** spinner 帧间隔(ms)。低频丝滑转动,只触发单行重绘不锁滚动。 */
45
+ const SPINNER_INTERVAL_MS = 200;
46
+
47
+ /** 压缩视图滚动区最多展示的 eventLog 条数(不含 currentActivity 行)。 */
48
+ const COMPACT_SCROLL_LINES = 3;
49
+
50
+ // ============================================================
51
+ // 类型(已存在的契约)
52
+ // ============================================================
53
+
54
+ /** renderResult 的 context(SDK 注入,含 lastComponent 供复用)。 */
55
+ export interface RenderContext {
56
+ state: Record<string, never>;
57
+ invalidate(): void;
58
+ lastComponent?: Component;
59
+ }
60
+
61
+ /** SubagentResultComponent 的 props 形状(TUI 组件内部状态)。 */
62
+ export interface SubagentResultProps {
63
+ details: SubagentToolDetails;
64
+ expanded: boolean;
65
+ theme: ThemeLike;
66
+ }
67
+
68
+ // ============================================================
69
+ // renderCall —— tool 标题行
70
+ // ============================================================
71
+
72
+ /**
73
+ * renderCall:tool 标题行(agent + model + thinking,不变信息)。
74
+ *
75
+ * "subagent worker · glm-5.2 · thinking high"
76
+ *
77
+ * model/thinkingLevel 由调用方(subagent-tool.ts 的闭包)预解析后传入,
78
+ * 因为 renderCall 在 execute 前调用,但 model 解析是同步的(只读配置)。
79
+ * resolved 缺失时(hub 未就绪)降级为只显示 agent 名。
80
+ *
81
+ * 返回 `new Text(line, 0, 0)`——paddingX=0 paddingY=0,背景交给 contentBox。
82
+ */
83
+ export function renderSubagentCall(
84
+ args: unknown,
85
+ theme: Theme,
86
+ _context: RenderContext,
87
+ resolved?: { model: string; thinkingLevel?: string },
88
+ ): Component {
89
+ const t = theme as ThemeLike;
90
+ const agent = extractAgentName(args);
91
+ const parts = [`${t.fg("toolTitle", t.bold("subagent "))}${t.fg("accent", agent)}`];
92
+
93
+ // model + thinking——完整 provider/model(accent 色),thinking 保持 dim。
94
+ // 不去 provider 前缀——provider 是模型来源的关键信息,感知「用错模型」需要完整路径。
95
+ if (resolved) {
96
+ parts.push(t.fg("dim", " ("));
97
+ parts.push(t.fg("accent", resolved.model));
98
+ if (resolved.thinkingLevel) {
99
+ parts.push(t.fg("dim", ` · thinking ${resolved.thinkingLevel})`));
100
+ } else {
101
+ parts.push(t.fg("dim", ")"));
102
+ }
103
+ }
104
+
105
+ return new Text(parts.join(""), 0, 0);
106
+ }
107
+
108
+ // ============================================================
109
+ // renderResult —— 对话流背景色 block(路由 + 复用)
110
+ // ============================================================
111
+
112
+ /**
113
+ * renderResult:对话流背景色 block。
114
+ *
115
+ * 1. details 缺失 → fallback new Text(防御性)
116
+ * 2. lastComponent instanceof SubagentResultComponent
117
+ * → comp.update(details, theme) + setExpanded(复用,省 GC)
118
+ * 3. 否则 new SubagentResultComponent(details, theme)
119
+ * 4. setExpanded(options.expanded)
120
+ *
121
+ * 背景色由 Pi default shell 的 contentBox 按 isPartial/isError 自动施加,
122
+ * 组件本身不施加背景色。
123
+ */
124
+ export function renderSubagentResult(
125
+ result: AgentToolResult<SubagentToolDetails>,
126
+ options: { expanded: boolean; isPartial: boolean },
127
+ theme: Theme,
128
+ context: RenderContext,
129
+ ): Component {
130
+ const themeLike = theme as ThemeLike;
131
+ const details = result.details;
132
+
133
+ // 防御性 fallback:details 缺失或结构不完整时显示占位。
134
+ // execute throw 后 SDK 会重建空 details({} 或 undefined),此时 status/agent 缺失。
135
+ // 用 warning 色明确标示「执行出错」,而非 dim 的「正常但无内容」——
136
+ // 让用户知道是异常而非预期行为。
137
+ if (!details || typeof details.status !== "string" || typeof details.agent !== "string") {
138
+ return new Text(themeLike.fg("warning", "(subagent execution failed — no details available)"), 0, 0);
139
+ }
140
+
141
+ // 复用 lastComponent(P1a 优化,省 GC + 防 theme 闪烁)
142
+ if (context.lastComponent instanceof SubagentResultComponent) {
143
+ const comp = context.lastComponent;
144
+ comp.update(details, themeLike);
145
+ comp.setExpanded(options.expanded);
146
+ comp.setInvalidate(context.invalidate);
147
+ return comp;
148
+ }
149
+
150
+ const comp = new SubagentResultComponent(details, themeLike);
151
+ comp.setExpanded(options.expanded);
152
+ comp.setInvalidate(context.invalidate);
153
+ return comp;
154
+ }
155
+
156
+ // ============================================================
157
+ // SubagentResultComponent —— 持久 TUI 组件
158
+ // ============================================================
159
+
160
+ /**
161
+ * SubagentResultComponent —— 持久 TUI 组件。
162
+ *
163
+ * update() 复用实例(省 GC),setExpanded 同步展开状态。
164
+ *
165
+ * spinner 驱动:running 态用低频 setInterval(SPINNER_INTERVAL_MS)调 context.invalidate()
166
+ * 触发重绘,每次 render 用 Date.now() 选帧——丝滑转动。
167
+ * terminal 态(done/failed/cancelled)clearInterval,spinner 停在终态图标。
168
+ *
169
+ * 安全性(对照 pi-tui 引擎源码确认):
170
+ * - setInterval 只触发 invalidate(→ requestRender),不改行数/不加 eventLog
171
+ * - Pi diff 引擎只重绘变化的行(tui.ts:1346「spinner animation」场景)
172
+ * - 行数不变 → finalCursorRow 不变 → viewportTop 不变(tui.ts:1445)→ 不 snap-back
173
+ * - 旧 Bug #4 根因是 setInterval 同时推了 eventLog(行数变化),不是定时器本身
174
+ *
175
+ * render 返回的 string[] 是**裸内容行**(状态行 + message stream 行),
176
+ * 不含背景色/padding——那些由 Pi 的 contentBox 施加。
177
+ */
178
+ export class SubagentResultComponent implements Component {
179
+ private details: SubagentToolDetails;
180
+ private theme: ThemeLike;
181
+ private expanded = false;
182
+ /** invalidate 回调(来自 SDK context,内部已含 requestRender)。running 态定时器调它驱动重绘。 */
183
+ private invalidateFn?: () => void;
184
+ /** spinner 定时器(running 态启动,terminal 态清除)。 */
185
+ private spinnerTimer?: ReturnType<typeof setInterval>;
186
+
187
+ constructor(details: SubagentToolDetails, theme: ThemeLike) {
188
+ this.details = details;
189
+ this.theme = theme;
190
+ }
191
+
192
+ /** 刷新 details + theme 引用(P1a 复用)。theme 必须随更新,否则 /theme 切换后显示错色。 */
193
+ update(details: SubagentToolDetails, theme: ThemeLike): void {
194
+ this.details = details;
195
+ this.theme = theme;
196
+ }
197
+
198
+ /** 注入 invalidate 回调(renderSubagentResult 从 context 传入)。 */
199
+ setInvalidate(fn: () => void): void {
200
+ this.invalidateFn = fn;
201
+ }
202
+
203
+ setExpanded(expanded: boolean): void {
204
+ this.expanded = expanded;
205
+ }
206
+
207
+ invalidate(): void {
208
+ // no-op:render 每次从 details 重建,无缓存。
209
+ }
210
+
211
+ render(width: number): string[] {
212
+ this.maybeToggleSpinner();
213
+ return this.expanded ? this.renderExpanded(width) : this.renderCompact(width);
214
+ }
215
+
216
+ /**
217
+ * 按状态启停 spinner 定时器。
218
+ * running → 启动(若未启动):setInterval 调 invalidate 触发重绘
219
+ * terminal → 清除:spinner 停在终态图标
220
+ *
221
+ * background 模式(details.backgroundId 存在)**不启动定时器**:
222
+ * background 的 tool block 在 execute return 后被 Pi finalize,之后无 onUpdate
223
+ * 更新。spinner 转了 eventLog 也不变,反而每次 invalidate→renderResult 会导致
224
+ * Pi 把 spinner 行当新内容追加(行数持续增长堆积)。background 进度靠 progress
225
+ * widget 展示,不靠 tool block。
226
+ */
227
+ private maybeToggleSpinner(): void {
228
+ const isBackgroundPlaceholder = this.details.backgroundId !== undefined;
229
+ if (this.details.status === "running" && !isBackgroundPlaceholder) {
230
+ if (this.spinnerTimer === undefined && this.invalidateFn) {
231
+ this.spinnerTimer = setInterval(() => {
232
+ this.invalidateFn!();
233
+ }, SPINNER_INTERVAL_MS);
234
+ }
235
+ } else if (this.spinnerTimer !== undefined) {
236
+ clearInterval(this.spinnerTimer);
237
+ this.spinnerTimer = undefined;
238
+ }
239
+ }
240
+
241
+ // ── 压缩视图 ──────────────────────────────────────────────
242
+
243
+ private renderCompact(width: number): string[] {
244
+ const d = this.details;
245
+ const theme = this.theme;
246
+
247
+ // background 占位 block(有 backgroundId):execute 已 return,tool block 不会再更新。
248
+ // 只显示 backgroundId + poll 指引,不显示 spinner/eventLog/footer(那些对 background 无意义)。
249
+ if (d.backgroundId !== undefined) {
250
+ return [
251
+ truncLine(
252
+ `${theme.fg("dim", "background: ")}${theme.fg("accent", d.backgroundId)}`
253
+ + ` ${theme.fg("dim", "· running detached · poll to check")}`,
254
+ width,
255
+ ),
256
+ ];
257
+ }
258
+
259
+ const lines: string[] = [];
260
+
261
+ // 第 1 行:状态行(glyph + stats,agent/model 已上移标题行)
262
+ lines.push(truncLine(buildStatusLine(d, theme), width));
263
+
264
+ // 滚动区:最近 N 条 eventLog(不含 turn_end),running 和 terminal 态统一展示。
265
+ // 先折叠连续同类分片(text/thinking 的 100 字符 chunk 合并为 1 条代表行),
266
+ // 再取最近 N 条——避免同一句话被拆成 N 个半句碎片。
267
+ const scrollEntries = foldEntries(d.eventLog.filter((e) => e.type !== "turn_end"));
268
+
269
+ // running 态:currentActivity 作为滚动区首行(实时"正在做什么"锚点),
270
+ // 并与 eventLog 末条去重(避免两行近乎相同)。
271
+ if (d.status === "running" && d.currentActivity) {
272
+ const lastEntry = scrollEntries[scrollEntries.length - 1];
273
+ const sameAsLast = lastEntry !== undefined && activityMatchesEntry(d.currentActivity, lastEntry);
274
+ if (!sameAsLast) {
275
+ lines.push(truncLine(buildActivityLine(d.currentActivity, theme), width));
276
+ }
277
+ }
278
+
279
+ // 最近 COMPACT_SCROLL_LINES 条 eventLog
280
+ for (const entry of scrollEntries.slice(-COMPACT_SCROLL_LINES)) {
281
+ lines.push(truncLine(`${theme.fg("dim", STREAM_PREFIX)}${formatEventLine(entry, theme)}`, width));
282
+ }
283
+
284
+ // running 态 footer:Ctrl+O 提示(纯空格缩进,不带 ⎿)
285
+ if (d.status === "running") {
286
+ lines.push(truncLine(`${theme.fg("dim", FOOTER_PREFIX)}${theme.fg("accent", "Press Ctrl+O for live detail")}`, width));
287
+ }
288
+
289
+ // terminal 态:交付物行(done=result首行 / failed=Error:... / cancelled=Cancelled)
290
+ if (d.status !== "running") {
291
+ const delivery = buildDeliveryLine(d, theme);
292
+ if (delivery) {
293
+ lines.push(truncLine(`${theme.fg("dim", STREAM_PREFIX)}${delivery}`, width));
294
+ }
295
+ }
296
+
297
+ return lines;
298
+ }
299
+
300
+ // ── 展开视图 ──────────────────────────────────────────────
301
+
302
+ private renderExpanded(width: number): string[] {
303
+ const lines: string[] = [];
304
+ const d = this.details;
305
+ const theme = this.theme;
306
+
307
+ // 状态行
308
+ lines.push(truncLine(buildStatusLine(d, theme), width));
309
+
310
+ // 空行间隔
311
+ lines.push("");
312
+
313
+ // 完整 eventLog(含 turn_end 分隔)
314
+ for (const entry of d.eventLog) {
315
+ lines.push(truncLine(`${theme.fg("dim",STREAM_PREFIX)}${formatEventLine(entry, theme)}`, width));
316
+ }
317
+
318
+ // 交付物(完整首行)
319
+ const delivery = buildDeliveryLine(d, theme);
320
+ if (delivery) {
321
+ lines.push("");
322
+ lines.push(truncLine(`${theme.fg("dim",STREAM_PREFIX)}${delivery}`, width));
323
+ }
324
+
325
+ return lines;
326
+ }
327
+ }
328
+
329
+ // ============================================================
330
+ // 私有 helper(模块内)
331
+ // ============================================================
332
+
333
+ // extractAgentName / firstLine 已上移到 ./format.ts 共享(tool-render / list-view /
334
+ // bg-notify-render / subagent-tool 复用)。
335
+
336
+ /**
337
+ * 构建状态行:`{glyph} {stats}`(agent/model 已上移标题行,由 renderCall 预解析)。
338
+ *
339
+ * glyph: running → seed-frame spinner(accent);done → ✓(success);failed → ✗(error);cancelled → ■(muted)
340
+ * stats: dim `· N turns · Nk · Ns`,各字段 > 0 才显示(全零省略)
341
+ */
342
+ function buildStatusLine(d: SubagentToolDetails, theme: ThemeLike): string {
343
+ const glyph = statusGlyph(d.status);
344
+ // running 用 Date.now() 选帧(setInterval 驱动丝滑转动);terminal 态 glyph.icon 有值。
345
+ // background 占位 block(有 backgroundId)用静态 ● 而非 spinner——execute 已 return,
346
+ // 不会有 onUpdate 更新,spinner 转了内容也不变,静态图标更准确表达「已派发后台运行」。
347
+ const icon = glyph.icon
348
+ ?? (d.backgroundId !== undefined ? "●" : spinnerGlyph(Math.floor(Date.now() / SPINNER_INTERVAL_MS)));
349
+ const glyphStr = theme.fg(glyph.color, icon);
350
+
351
+ // stats:· N turns · Nk · Ns,零值隐藏
352
+ const statsStr = buildStats(d, theme);
353
+ const statsPrefix = statsStr ? ` ${theme.fg("dim", "·")} ${statsStr}` : "";
354
+
355
+ return `${glyphStr}${statsPrefix}`;
356
+ }
357
+
358
+ /**
359
+ * 构建 stats 字符串:`N turns · Nk · Ns`(零值隐藏,全零返回 "")。
360
+ * 各字段 dim 色,用 `·` 分隔(spec 分隔符语义:同级并列字段)。
361
+ */
362
+ function buildStats(d: SubagentToolDetails, theme: ThemeLike): string {
363
+ const parts: string[] = [];
364
+ if (d.turns > 0) parts.push(`${d.turns} turns`);
365
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
366
+ if (d.elapsedSeconds > 0) parts.push(formatElapsedSeconds(d.elapsedSeconds));
367
+ if (parts.length === 0) return "";
368
+ return parts.map((p) => theme.fg("dim", p)).join(` ${theme.fg("dim", "·")} `);
369
+ }
370
+
371
+ /**
372
+ * 构建 currentActivity 行:` ⎿ {图标} {label}`。
373
+ * 图标按 activity.type:tool→`›`、thinking→`·`、text→`>`。
374
+ * label 经 sanitize 压成单行。
375
+ */
376
+ function buildActivityLine(
377
+ activity: { type: "tool" | "text" | "thinking"; label: string },
378
+ theme: ThemeLike,
379
+ ): string {
380
+ const tag = activity.type === "tool" ? "tool:" : activity.type === "thinking" ? "thinking:" : "text:";
381
+ const label = sanitizeLabel(activity.label);
382
+ // thinking 整行 dim(含标签);其他标签 normal,前缀 dim
383
+ const content = activity.type === "thinking"
384
+ ? theme.fg("dim",`${tag} ${label}`)
385
+ : `${tag} ${label}`;
386
+ return `${theme.fg("dim",STREAM_PREFIX)}${content}`;
387
+ }
388
+
389
+ /**
390
+ * 构建 terminal 态交付物行内容(不含 STREAM_PREFIX,由调用方加)。
391
+ * done → result 首行(normal 色)
392
+ * failed → `Error: {error 首行}`(error 色)
393
+ * cancelled → `Cancelled`(dim)
394
+ */
395
+ function buildDeliveryLine(d: SubagentToolDetails, theme: ThemeLike): string | undefined {
396
+ switch (d.status) {
397
+ case "done":
398
+ return firstLineSanitized(d.result) || undefined;
399
+ case "failed":
400
+ return `${theme.fg("error", "Error:")}: ${firstLineSanitized(d.error)}`;
401
+ case "cancelled":
402
+ return theme.fg("dim","Cancelled");
403
+ default:
404
+ return undefined;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * 取文本首个非空行(多行压成首行展示),并 sanitize。
410
+ * 用于 done/failed 的交付物预览。
411
+ * 共享 firstLine(./format.ts)取首行,本 wrapper 叠加 sanitizeLabel。
412
+ */
413
+ function firstLineSanitized(text?: string): string {
414
+ return sanitizeLabel(firstLine(text));
415
+ }
416
+
417
+ /**
418
+ * 判断 currentActivity 是否与某条 eventLog 末条语义重复。
419
+ *
420
+ * running 时 currentActivity(实时 streaming 锚点)可能正好是 eventLog 末条
421
+ * 正在跑的 tool_start(同 label)。此时滚动区不再重复铺该条,避免两行近乎相同。
422
+ *
423
+ * activity.type === "tool" 且 entry 是 tool_start + status:"running" + 同 label → 重复
424
+ * 其他情况 → 不重复(thinking/text 的 streaming 与 eventLog 分片语义不同)
425
+ */
426
+ function activityMatchesEntry(
427
+ activity: { type: "tool" | "text" | "thinking"; label: string },
428
+ entry: { type: string; label: string; status?: string },
429
+ ): boolean {
430
+ if (activity.type !== "tool") return false;
431
+ if (entry.type !== "tool_start") return false;
432
+ if (entry.status !== "running") return false;
433
+ return sanitizeLabel(activity.label) === sanitizeLabel(entry.label);
434
+ }
435
+
436
+ /**
437
+ * 折叠连续同类分片(text_output / thinking)为单条代表行。
438
+ *
439
+ * 问题:core 层把流式输出按 100 字符切成多个 chunk push 进 eventLog。
440
+ * 压缩视图逐条显示这些 chunk,结果同一句话被拆成 N 个半句碎片(前 100 字符重复 N 次),可读性差。
441
+ *
442
+ * 解法:相邻且同类(text_output 或 thinking)的分片折叠为 1 条,
443
+ * label 取组内**最后一条**(最新内容,反映流式进展)。被 tool 隔开的同类各自成组。
444
+ *
445
+ * [text, text, text, tool, text] → [text(末), tool, text(末)]
446
+ *
447
+ * 纯渲染层折叠,不改 eventLog 本身(持久化仍是细粒度)。
448
+ * expanded view 不折叠(那里用户想看完整内容)。
449
+ */
450
+ function foldEntries(entries: AgentEventLogEntry[]): AgentEventLogEntry[] {
451
+ const result: AgentEventLogEntry[] = [];
452
+ for (const entry of entries) {
453
+ const last = result[result.length - 1];
454
+ // 相邻同类(text_output 或 thinking)→ 合并,取最新 label + ts
455
+ if (
456
+ last !== undefined &&
457
+ last.type === entry.type &&
458
+ (entry.type === "text_output" || entry.type === "thinking")
459
+ ) {
460
+ // readonly 字段不能 mutate,替换整个元素
461
+ result[result.length - 1] = { ...last, label: entry.label, ts: entry.ts };
462
+ } else {
463
+ result.push({ ...entry });
464
+ }
465
+ }
466
+ return result;
467
+ }