From 4953bfed1a0eaafabd1d03bfb820adbbd09455f7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 19 Jun 2026 22:18:25 -0700 Subject: [PATCH 1/2] fix(chat): merge consecutive reasoning steps into one pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-step agent turn (reason → tool → reason → tool → answer) produced a stack of separate "Thought for 1s" pills — one per assistant reasoning message, with the hidden tool messages between them. This collapses a reasoning RUN (a maximal sequence of consecutive assistant reasoning steps separated only by hidden tool messages) into a single pill rendered at the run's first step: "Thought for {total} · {N} steps", expandable to the joined reasoning. Single-step turns keep the normal "Thought for {duration}" pill (label falls back when N == 1). Adds reasoningRunStart()/reasoningRun() helpers on ChatComponent; uses the existing chat-reasoning [label] input + resolvedLabel() fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/compositions/chat/chat.component.ts | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index d6253951..333bb190 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -36,6 +36,7 @@ import { createPartialArgsBridge, type PartialArgsBridge } from '../../a2ui/part import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../../a2ui/surface-store'; import { a2uiActionLabel } from '../../a2ui/action-label'; import { messageContent } from '../shared/message-utils'; +import { formatDuration } from '../../utils/format-duration'; import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens'; import type { ChatRenderEvent } from './chat-render-event'; import { CHAT_LIFECYCLE, type ChatLifecycle } from '../../lifecycle'; @@ -197,11 +198,19 @@ export function isPinned( [streaming]="agent().isLoading() && i === agent().messages().length - 1" [current]="i === agent().messages().length - 1" > - @if (message.reasoning) { + + @if (message.reasoning && reasoningRunStart(i)) { + @let run = reasoningRun(i); } @@ -461,6 +470,59 @@ export class ChatComponent { return text.length === 0; } + /** The nearest preceding assistant message (skipping hidden tool messages), or undefined. */ + private prevAssistant(msgs: Message[], index: number): Message | undefined { + for (let j = index - 1; j >= 0; j--) { + if (msgs[j].role === 'tool') continue; + return msgs[j].role === 'assistant' ? msgs[j] : undefined; + } + return undefined; + } + + /** + * True when message[index] starts a reasoning RUN — a maximal sequence of + * consecutive assistant reasoning steps separated only by (hidden) tool + * messages. The merged reasoning pill renders once, here. + */ + protected reasoningRunStart(index: number): boolean { + const msgs = this.agent().messages(); + if (!msgs[index]?.reasoning) return false; + return !this.prevAssistant(msgs, index)?.reasoning; + } + + /** + * Aggregate the reasoning RUN starting at `index`: joins each step's + * reasoning, sums durations, counts steps, and computes the streaming flag + * and the merged label ("Thought for {total} · {N} steps" when N > 1). + */ + protected reasoningRun(index: number): { + content: string; + durationMs: number | undefined; + streaming: boolean; + label: string | undefined; + } { + const msgs = this.agent().messages(); + const steps: { msg: Message; idx: number }[] = []; + for (let j = index; j < msgs.length; j++) { + const m = msgs[j]; + if (m.role === 'tool') continue; // skip hidden tool messages + if (m.role === 'assistant' && m.reasoning) { steps.push({ msg: m, idx: j }); continue; } + break; // any other message ends the run + } + const content = steps.map((s) => s.msg.reasoning ?? '').filter(Boolean).join('\n\n'); + const durations = steps + .map((s) => s.msg.reasoningDurationMs) + .filter((d): d is number => typeof d === 'number'); + const durationMs = durations.length ? durations.reduce((a, b) => a + b, 0) : undefined; + const last = steps[steps.length - 1]; + const streaming = last ? this.isReasoningStreaming(last.msg, last.idx) : false; + const label = + steps.length > 1 + ? `Thought for ${formatDuration(durationMs ?? 0)} · ${steps.length} steps` + : undefined; + return { content, durationMs, streaming, label }; + } + private readonly classifiers = new Map(); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); From d2f884b1e903abc3e6b5c614ea55ebea666eed06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:23:00 +0000 Subject: [PATCH 2/2] chore(docs): regenerate api docs --- .../content/docs/chat/api/api-docs.json | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 2044c513..0eb91d92 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -2068,6 +2068,32 @@ } ] }, + { + "name": "reasoningRun", + "signature": "reasoningRun(index: number): object", + "description": "Aggregate the reasoning RUN starting at `index`: joins each step's\nreasoning, sums durations, counts steps, and computes the streaming flag\nand the merged label (\"Thought for {total} · {N} steps\" when N > 1).", + "params": [ + { + "name": "index", + "type": "number", + "description": "", + "optional": false + } + ] + }, + { + "name": "reasoningRunStart", + "signature": "reasoningRunStart(index: number): boolean", + "description": "True when message[index] starts a reasoning RUN — a maximal sequence of\nconsecutive assistant reasoning steps separated only by (hidden) tool\nmessages. The merged reasoning pill renders once, here.", + "params": [ + { + "name": "index", + "type": "number", + "description": "", + "optional": false + } + ] + }, { "name": "submitMessage", "signature": "submitMessage(text: string): void",