Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 66 additions & 4 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -197,11 +198,19 @@ export function isPinned(
[streaming]="agent().isLoading() && i === agent().messages().length - 1"
[current]="i === agent().messages().length - 1"
>
@if (message.reasoning) {
<!-- Reasoning is merged across a run of consecutive (tool-
separated) reasoning steps and rendered ONCE at the run's
first step as "Thought for {total} · {N} steps", so a
multi-step agent shows one compact pill instead of a
stack of "Thought for 1s" chips. Single-step turns render
a normal "Thought for {duration}" pill. -->
@if (message.reasoning && reasoningRunStart(i)) {
@let run = reasoningRun(i);
<chat-reasoning
[content]="message.reasoning"
[isStreaming]="isReasoningStreaming(message, i)"
[durationMs]="message.reasoningDurationMs"
[content]="run.content"
[isStreaming]="run.streaming"
[durationMs]="run.durationMs"
[label]="run.label"
/>
}
<chat-tool-calls [agent]="agent()" [message]="message" [excludeToolNames]="excludedToolNames()">
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When every step has reasoningDurationMs: undefined, durationMs is undefined here, so durationMs ?? 0 passes 0 to formatDuration, producing "Thought for <1s · N steps"<1s implies it was fast, but the real meaning is "no timing data". Consider omitting the duration portion when unknown:

Suggested change
: undefined;
? steps.length > 1 && durationMs !== undefined
? `Thought for ${formatDuration(durationMs)} · ${steps.length} steps`
: steps.length > 1
? `${steps.length} steps`
: undefined
: undefined;

Or at minimum document the fallback intent with a comment. (Low severity — timing data is almost always present in practice.)

return { content, durationMs, streaming, label };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coveragereasoningRunStart and reasoningRun are the core of this feature but have no unit tests. The logic has several non-trivial branches worth covering:

  • Single reasoning step (no merge) → label is undefined, streaming comes from isReasoningStreaming.
  • Two steps separated by a tool message → pill merges both.
  • Run ends when a non-reasoning, non-tool message follows.
  • All reasoningDurationMs values are undefineddurationMs stays undefined, label falls back to formatDuration(0) = "<1s".
  • First step is the tail (streaming) vs last step is the tail.

These are good candidates for straight unit tests on the class methods (no template compile needed).

Fix this →


private readonly classifiers = new Map<string, ContentClassifier>();
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
Expand Down