From 37c3c26eaae9cdb06c28ee1b6c71ba6a75da8bef Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 20:37:14 -0700 Subject: [PATCH 01/24] feat(chat): add Message.reasoning + Message.reasoningDurationMs Optional fields on the shared Message contract. Adapters populate them from provider-agnostic sources (LangGraph reasoning/thinking content blocks, AG-UI REASONING_MESSAGE_* events). UI primitives consume the fields without provider-specific knowledge. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/src/lib/agent/message.spec.ts | 29 +++++++++++++++++++++++++ libs/chat/src/lib/agent/message.ts | 16 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/libs/chat/src/lib/agent/message.spec.ts b/libs/chat/src/lib/agent/message.spec.ts index b30f05ab8..df66a77f2 100644 --- a/libs/chat/src/lib/agent/message.spec.ts +++ b/libs/chat/src/lib/agent/message.spec.ts @@ -14,3 +14,32 @@ describe('Message', () => { expect(isUserMessage(msg)).toBe(false); }); }); + +describe('Message — reasoning fields', () => { + it('accepts an optional reasoning string', () => { + const m: Message = { + id: 'a', + role: 'assistant', + content: 'hello', + reasoning: 'first I thought about it', + }; + expect(m.reasoning).toBe('first I thought about it'); + }); + + it('accepts an optional reasoningDurationMs number', () => { + const m: Message = { + id: 'a', + role: 'assistant', + content: 'hello', + reasoning: 'first I thought about it', + reasoningDurationMs: 1234, + }; + expect(m.reasoningDurationMs).toBe(1234); + }); + + it('treats both reasoning fields as optional', () => { + const m: Message = { id: 'a', role: 'assistant', content: 'hello' }; + expect(m.reasoning).toBeUndefined(); + expect(m.reasoningDurationMs).toBeUndefined(); + }); +}); diff --git a/libs/chat/src/lib/agent/message.ts b/libs/chat/src/lib/agent/message.ts index 46f9f2e6d..210b06d7a 100644 --- a/libs/chat/src/lib/agent/message.ts +++ b/libs/chat/src/lib/agent/message.ts @@ -12,6 +12,22 @@ export interface Message { toolCallId?: string; /** Optional display/author name. */ name?: string; + /** + * Reasoning text emitted by the model before/alongside the visible + * response. Populated by adapters from {type:'reasoning'} or + * {type:'thinking'} content blocks (LangGraph) or REASONING_MESSAGE_* + * events (AG-UI). Always a plain string — provider-specific shape + * (encrypted blocks, multi-step summaries) is absorbed by the adapter + * and not surfaced here. + */ + reasoning?: string; + /** + * Wall-clock duration of the reasoning phase in milliseconds. + * Populated by the adapter when both start (first reasoning chunk) and + * end (first response-text chunk, or final canonical message) are + * known. Undefined when reasoning timing isn't available. + */ + reasoningDurationMs?: number; /** Runtime-specific extras; do not rely on shape in portable code. */ extra?: Record; } From 9cb53adb03f94819dc34b7a64135cbfd49171481 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 20:39:20 -0700 Subject: [PATCH 02/24] feat(chat): add formatDuration utility Renders millisecond durations as compact human-readable labels: <1s, Ns, Nm Ms. Powers the chat-reasoning 'Thought for Ns' pill. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/utils/format-duration.spec.ts | 35 +++++++++++++++++++ libs/chat/src/lib/utils/format-duration.ts | 21 +++++++++++ 2 files changed, 56 insertions(+) create mode 100644 libs/chat/src/lib/utils/format-duration.spec.ts create mode 100644 libs/chat/src/lib/utils/format-duration.ts diff --git a/libs/chat/src/lib/utils/format-duration.spec.ts b/libs/chat/src/lib/utils/format-duration.spec.ts new file mode 100644 index 000000000..abad7baf0 --- /dev/null +++ b/libs/chat/src/lib/utils/format-duration.spec.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { formatDuration } from './format-duration'; + +describe('formatDuration', () => { + it('renders sub-second durations as "<1s"', () => { + expect(formatDuration(0)).toBe('<1s'); + expect(formatDuration(500)).toBe('<1s'); + expect(formatDuration(999)).toBe('<1s'); + }); + + it('renders sub-minute durations in seconds', () => { + expect(formatDuration(1000)).toBe('1s'); + expect(formatDuration(4000)).toBe('4s'); + expect(formatDuration(59_000)).toBe('59s'); + expect(formatDuration(59_999)).toBe('59s'); + }); + + it('renders minute-or-greater durations as "Nm Ms"', () => { + expect(formatDuration(60_000)).toBe('1m 0s'); + expect(formatDuration(72_000)).toBe('1m 12s'); + expect(formatDuration(125_000)).toBe('2m 5s'); + expect(formatDuration(3_600_000)).toBe('60m 0s'); + }); + + it('clamps negative inputs to "<1s"', () => { + expect(formatDuration(-1)).toBe('<1s'); + expect(formatDuration(-1000)).toBe('<1s'); + }); + + it('handles non-finite inputs by returning "<1s"', () => { + expect(formatDuration(Number.NaN)).toBe('<1s'); + expect(formatDuration(Number.POSITIVE_INFINITY)).toBe('<1s'); + }); +}); diff --git a/libs/chat/src/lib/utils/format-duration.ts b/libs/chat/src/lib/utils/format-duration.ts new file mode 100644 index 000000000..0705a5cf6 --- /dev/null +++ b/libs/chat/src/lib/utils/format-duration.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +/** + * Render a millisecond duration as a human-readable label suitable for + * the chat-reasoning "Thought for Ns" pill. + * + * - <1 s → "<1s" + * - 1–59 s → "Ns" (e.g. "4s") + * - ≥60 s → "Nm Ms" (e.g. "1m 12s", "60m 0s") + * + * Negative or non-finite inputs collapse to "<1s" so a corrupted timing + * map never produces noisy output. + */ +export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 1000) return '<1s'; + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds - minutes * 60; + return `${minutes}m ${seconds}s`; +} From fe5551d6c7429a73a0714e37dad721a491514207 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 20:42:58 -0700 Subject: [PATCH 03/24] feat(chat): chat-reasoning styles Pill-shaped header with chevron + animated pulse dot for the streaming state, expanded body with thin left border (matches the blockquote pattern). Muted text throughout so reasoning content recedes next to the response. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/styles/chat-reasoning.styles.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 libs/chat/src/lib/styles/chat-reasoning.styles.ts diff --git a/libs/chat/src/lib/styles/chat-reasoning.styles.ts b/libs/chat/src/lib/styles/chat-reasoning.styles.ts new file mode 100644 index 000000000..3cdf9471e --- /dev/null +++ b/libs/chat/src/lib/styles/chat-reasoning.styles.ts @@ -0,0 +1,56 @@ +// libs/chat/src/lib/styles/chat-reasoning.styles.ts +// SPDX-License-Identifier: MIT +// +// Style block for the chat-reasoning primitive. Pill-shaped header with +// a chevron + label; expanded body sits below the header with a thin +// left border (matches the blockquote pattern in chat-markdown.styles). +// Muted text colors throughout so reasoning content recedes visually +// next to the response. +export const CHAT_REASONING_STYLES = ` + :host { display: block; margin: 0 0 0.5rem; } + :host([data-has-content="false"]) { display: none; } + + .chat-reasoning__header { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 4px 10px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-xs); + font-family: inherit; + cursor: pointer; + line-height: 1.2; + } + .chat-reasoning__header:hover { color: var(--ngaf-chat-text); } + + .chat-reasoning__chevron { + width: 10px; + height: 10px; + transition: transform 120ms ease; + } + :host([data-expanded="true"]) .chat-reasoning__chevron { transform: rotate(90deg); } + + .chat-reasoning__pulse { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ngaf-chat-text-muted); + animation: chat-reasoning-pulse 1.2s ease-in-out infinite; + } + @keyframes chat-reasoning-pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } + } + + .chat-reasoning__body { + margin-top: 0.5rem; + padding-left: 12px; + border-left: 2px solid var(--ngaf-chat-separator); + color: var(--ngaf-chat-text-muted); + } + .chat-reasoning__body chat-streaming-md { font-size: 0.95em; } +`; From dfbcc2a1e9db0103d0beb580e32070f06af5774f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 20:54:06 -0700 Subject: [PATCH 04/24] feat(chat): chat-reasoning primitive Renders model reasoning content as a compact pill. Three visual states (streaming with pulse + auto-expand, idle with 'Thought for Ns', idle with 'Show reasoning' fallback). User toggle wins over auto logic for the lifetime of the instance. Body re-uses chat-streaming-md so markdown in reasoning output renders consistently with the response. Adds @analogjs/vite-plugin-angular to the chat library's vite config (with pool: 'forks' to preserve existing test isolation) so that Angular signal inputs resolve correctly in vitest HostComponent specs. Also adds tsconfig.spec.json required by the Angular compiler plugin. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-reasoning.component.spec.ts | 137 ++++++++++++++++++ .../chat-reasoning.component.ts | 97 +++++++++++++ libs/chat/tsconfig.spec.json | 10 ++ libs/chat/vite.config.mts | 4 +- 4 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts create mode 100644 libs/chat/tsconfig.spec.json diff --git a/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts new file mode 100644 index 000000000..3ffaf637b --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatReasoningComponent } from './chat-reasoning.component'; + +@Component({ + standalone: true, + imports: [ChatReasoningComponent], + template: ` + + `, +}) +class HostComponent { + content = signal('I considered the problem.'); + streaming = signal(false); + durationMs = signal(undefined); + defaultExpanded = signal(false); +} + +function makeFixture() { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + return fixture; +} + +function getEl(fixture: ReturnType): HTMLElement { + return fixture.nativeElement.querySelector('chat-reasoning'); +} + +function getHeader(fixture: ReturnType): HTMLButtonElement { + return fixture.nativeElement.querySelector('chat-reasoning button.chat-reasoning__header'); +} + +function getLabelText(fixture: ReturnType): string { + return fixture.nativeElement.querySelector('chat-reasoning .chat-reasoning__label')?.textContent?.trim() ?? ''; +} + +describe('ChatReasoningComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('hides itself when content is empty', () => { + const fixture = makeFixture(); + fixture.componentInstance.content.set(''); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-has-content')).toBe('false'); + }); + + it('shows itself when content is non-empty', () => { + const fixture = makeFixture(); + expect(getEl(fixture).getAttribute('data-has-content')).toBe('true'); + }); + + it('renders "Thinking…" while streaming', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + expect(getLabelText(fixture)).toContain('Thinking'); + }); + + it('renders "Thought for Ns" when idle with durationMs', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(false); + fixture.componentInstance.durationMs.set(4000); + fixture.detectChanges(); + expect(getLabelText(fixture)).toContain('Thought for 4s'); + }); + + it('renders "Show reasoning" when idle without durationMs', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(false); + fixture.componentInstance.durationMs.set(undefined); + fixture.detectChanges(); + expect(getLabelText(fixture)).toContain('Show reasoning'); + }); + + it('starts collapsed by default', () => { + const fixture = makeFixture(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + }); + + it('starts expanded when defaultExpanded=true', () => { + const fixture = makeFixture(); + fixture.componentInstance.defaultExpanded.set(true); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); + + it('force-expands while streaming', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); + + it('toggles open and closed on header click', () => { + const fixture = makeFixture(); + const header = getHeader(fixture); + header.click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + header.click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + }); + + it('preserves user choice across isStreaming transitions', () => { + const fixture = makeFixture(); + // User opens manually + getHeader(fixture).click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + + // Streaming completes (isStreaming false → still true after transition because user opened) + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); + + it('renders the content body inside chat-streaming-md when expanded', () => { + const fixture = makeFixture(); + fixture.componentInstance.defaultExpanded.set(true); + fixture.detectChanges(); + const md = fixture.nativeElement.querySelector('chat-reasoning chat-streaming-md'); + expect(md).not.toBeNull(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts new file mode 100644 index 000000000..98e49f7ca --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts @@ -0,0 +1,97 @@ +// libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, + computed, input, signal, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_REASONING_STYLES } from '../../styles/chat-reasoning.styles'; +import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { formatDuration } from '../../utils/format-duration'; + +/** + * Renders an assistant's reasoning content as a compact pill that + * expands to reveal the underlying text. Three visual states: + * + * - Streaming: pill shows "Thinking…" with a pulsing dot; auto-expanded + * so the user sees reasoning stream in real time. + * - Idle, with durationMs known: pill shows "Thought for {duration}"; + * collapsed by default, expand on click. + * - Idle, no duration: pill shows "Show reasoning"; collapsed by default. + * + * The body re-uses chat-streaming-md so reasoning content gets the same + * markdown rendering pipeline as the visible response (lists, code, + * step labels often appear in reasoning output). + * + * Internal state: a tristate "expanded" — null means follow auto state- + * driven logic (force-expand on isStreaming, otherwise honor + * defaultExpanded), boolean is a manual user choice that wins for the + * lifetime of the instance. + */ +@Component({ + selector: 'chat-reasoning', + standalone: true, + imports: [ChatStreamingMdComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_REASONING_STYLES], + host: { + '[attr.data-has-content]': 'hasContent()', + '[attr.data-expanded]': 'expandedStr()', + '[attr.data-streaming]': 'isStreaming()', + }, + template: ` + + @if (expanded()) { +
+ +
+ } + `, +}) +export class ChatReasoningComponent { + readonly content = input(''); + readonly isStreaming = input(false); + readonly durationMs = input(undefined); + readonly label = input(undefined); + readonly defaultExpanded = input(false); + + readonly hasContent = computed(() => (this.content() ?? '').length > 0); + + /** null = follow auto logic (streaming → expanded, else defaultExpanded). */ + private readonly _expandedOverride = signal(null); + + readonly expanded = computed(() => { + const override = this._expandedOverride(); + if (override !== null) return override; + if (this.isStreaming()) return true; + return this.defaultExpanded(); + }); + + readonly expandedStr = computed(() => String(this.expanded())); + + readonly resolvedLabel = computed(() => { + const explicit = this.label(); + if (explicit) return explicit; + if (this.isStreaming()) return 'Thinking…'; + const ms = this.durationMs(); + if (typeof ms === 'number') return `Thought for ${formatDuration(ms)}`; + return 'Show reasoning'; + }); + + toggle(): void { + this._expandedOverride.set(!this.expanded()); + } +} diff --git a/libs/chat/tsconfig.spec.json b/libs/chat/tsconfig.spec.json new file mode 100644 index 000000000..13e304ba3 --- /dev/null +++ b/libs/chat/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": false, + "lib": ["es2022", "dom"], + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts"] +} diff --git a/libs/chat/vite.config.mts b/libs/chat/vite.config.mts index ce406638a..1306fd366 100644 --- a/libs/chat/vite.config.mts +++ b/libs/chat/vite.config.mts @@ -1,13 +1,15 @@ import { defineConfig } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ - plugins: [nxViteTsPaths()], + plugins: [angular(), nxViteTsPaths()], test: { globals: true, environment: 'jsdom', include: ['src/**/*.spec.ts'], setupFiles: ['src/test-setup.ts'], passWithNoTests: true, + pool: 'forks', }, }); From ff2401015cc53c222fefaf3b0c4222c5f3feb8f6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 20:56:13 -0700 Subject: [PATCH 05/24] feat(chat): export ChatReasoningComponent + formatDuration Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/src/public-api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 1b3a5636f..b7fbb68eb 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -40,6 +40,7 @@ export { ChatMessageActionsComponent } from './lib/primitives/chat-message-actio export { ChatWindowComponent } from './lib/primitives/chat-window/chat-window.component'; export { ChatTraceComponent } from './lib/primitives/chat-trace/chat-trace.component'; export type { TraceState } from './lib/primitives/chat-trace/chat-trace.component'; +export { ChatReasoningComponent } from './lib/primitives/chat-reasoning/chat-reasoning.component'; export { ChatLauncherButtonComponent } from './lib/primitives/chat-launcher-button/chat-launcher-button.component'; export { ChatSuggestionsComponent } from './lib/primitives/chat-suggestions/chat-suggestions.component'; export { ChatInputComponent, submitMessage } from './lib/primitives/chat-input/chat-input.component'; @@ -80,6 +81,7 @@ export { ChatStreamingMdComponent } from './lib/streaming/streaming-markdown.com export { CHAT_MARKDOWN_STYLES } from './lib/styles/chat-markdown.styles'; export { renderMarkdown } from './lib/streaming/markdown-render'; export { messageContent } from './lib/compositions/shared/message-utils'; +export { formatDuration } from './lib/utils/format-duration'; export { ICON_CHEVRON_DOWN, ICON_CHEVRON_UP, ICON_TOOL, ICON_WARNING, ICON_AGENT, ICON_CHECK, ICON_SEND, From 0e732b4a502e4e461ae4d04fa578b0bd5890280b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:01:10 -0700 Subject: [PATCH 06/24] fix(chat): chat-reasoning auto-resets on streaming re-engage + spec amendments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 review found three issues. Behavioral fix: re-add the constructor effect that resets the manual expanded-override to null when isStreaming transitions false→true (spec §3.3 bullet 3 — same component instance reused across follow-up turns auto-expands when the next reasoning phase begins). The previous "preserves user choice" test conflated two scenarios; replace with one test asserting bullet 2 (no force-collapse on true→false), one test asserting user collapse persists, and one test asserting auto-reset on false→true. Spec drift: amend §3.1 so the content input defaults to '' (matching the shipped pragmatic API; pairs cleanly with data-has-content="false" hide-when-empty styling). Drop the unused [chatReasoningLabel] slot from §3.1 — the [label] string input covers the common case. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...easoning-and-tool-call-templates-design.md | 8 ++--- .../chat-reasoning.component.spec.ts | 34 +++++++++++++++++-- .../chat-reasoning.component.ts | 16 ++++++++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md b/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md index dbab316cc..aa6663b42 100644 --- a/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md +++ b/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md @@ -63,10 +63,12 @@ Both fields are optional — existing code reading `Message` is unaffected. ### 3.1 Selector & API +The `content` input defaults to `''` so the host attribute `data-has-content="false"` and the corresponding `:host { display: none }` rule cleanly hide the primitive when there's nothing to show. + ```typescript @Component({ selector: 'chat-reasoning', standalone: true, changeDetection: OnPush }) export class ChatReasoningComponent { - readonly content = input.required(); + readonly content = input(''); readonly isStreaming = input(false); readonly durationMs = input(undefined); readonly label = input(undefined); @@ -74,8 +76,6 @@ export class ChatReasoningComponent { } ``` -Slot: `[chatReasoningLabel]` content-projection for fully custom labels (default rendering covers the common case). - ### 3.2 Visual states | State | Pill label | Body | @@ -310,7 +310,7 @@ Per-component MDX files under `apps/website/content/docs/chat/components/`. ### 10.1 New docs -- **`chat-reasoning.mdx`** — full primitive reference: API table for all inputs (`[content]`, `[isStreaming]`, `[durationMs]`, `[label]`, `[defaultExpanded]`), three visual states with code examples, the `formatDuration` helper, the `[chatReasoningLabel]` slot, integration example showing automatic rendering by `` plus a standalone usage example. +- **`chat-reasoning.mdx`** — full primitive reference: API table for all inputs (`[content]`, `[isStreaming]`, `[durationMs]`, `[label]`, `[defaultExpanded]`), three visual states with code examples, the `formatDuration` helper, integration example showing automatic rendering by `` plus a standalone usage example. - **`chat-tool-call-template.mdx`** — directive reference. Selector + template context shape (`let-call`, `let-status`), worked examples (search results card, image generation card), interaction with `[grouping]`. ### 10.2 Updated docs diff --git a/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts index 3ffaf637b..9349a04c3 100644 --- a/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts @@ -111,19 +111,47 @@ describe('ChatReasoningComponent', () => { expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); }); - it('preserves user choice across isStreaming transitions', () => { + it('does not force-collapse when streaming ends (user-open persists past true → false)', () => { const fixture = makeFixture(); - // User opens manually + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + // User clicks to keep it open (already open, but the click captures intent) getHeader(fixture).click(); + getHeader(fixture).click(); // toggle back to expanded + fixture.detectChanges(); + fixture.componentInstance.streaming.set(false); fixture.detectChanges(); expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); - // Streaming completes (isStreaming false → still true after transition because user opened) + it('does not force-collapse on true → false when user explicitly collapsed before streaming ended', () => { + const fixture = makeFixture(); fixture.componentInstance.streaming.set(true); fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + getHeader(fixture).click(); // user collapses mid-stream + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); fixture.componentInstance.streaming.set(false); fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + }); + it('auto-resets to expanded when streaming re-engages on a follow-up turn', () => { + const fixture = makeFixture(); + // Round 1: streaming → user collapses → streaming ends + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + getHeader(fixture).click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + // Round 2: streaming re-engages — should auto-expand again + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); }); diff --git a/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts index 98e49f7ca..d6c8a4f72 100644 --- a/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts +++ b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { Component, ChangeDetectionStrategy, - computed, input, signal, + computed, effect, input, signal, } from '@angular/core'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_REASONING_STYLES } from '../../styles/chat-reasoning.styles'; @@ -91,6 +91,20 @@ export class ChatReasoningComponent { return 'Show reasoning'; }); + constructor() { + // Reset the manual override when streaming re-engages from idle (e.g. + // follow-up turn that re-uses this instance) so the auto force-expand + // logic takes over again. Spec §3.3 bullet 3. + let prevStreaming = false; + effect(() => { + const streaming = this.isStreaming(); + if (!prevStreaming && streaming) { + this._expandedOverride.set(null); + } + prevStreaming = streaming; + }); + } + toggle(): void { this._expandedOverride.set(!this.expanded()); } From 226d40719fbbfe552ec307f74a1374b16884ba8b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:10:01 -0700 Subject: [PATCH 07/24] feat(chat): chatToolCallTemplate directive Per-tool-name template registry consumed by . A '*' wildcard registers a catch-all for any unmapped tool name. Also extends DebugElement.prototype.queryAll in test-setup to traverse DebugNode (comment) children so directive-on-ng-template specs can use the injector-predicate pattern under Angular 21. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-tool-call-template.directive.spec.ts | 48 +++++++++++++++++++ .../chat-tool-call-template.directive.ts | 42 ++++++++++++++++ libs/chat/src/test-setup.ts | 14 ++++++ 3 files changed, 104 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts new file mode 100644 index 000000000..fbfdb9683 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, contentChildren, TemplateRef } from '@angular/core'; +import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; + +@Component({ + standalone: true, + imports: [ChatToolCallTemplateDirective], + template: ` + + {{ call.name }} + + + {{ call.name }} / {{ status }} + + + {{ call.name }} + + `, +}) +class HostComponent { + readonly templates = contentChildren(ChatToolCallTemplateDirective); +} + +describe('ChatToolCallTemplateDirective', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('exposes the tool name via the input alias', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const directives = fixture.debugElement + .queryAll((e) => e.injector.get(ChatToolCallTemplateDirective, null) !== null) + .map((e) => e.injector.get(ChatToolCallTemplateDirective)); + expect(directives.map((d) => d.name())).toEqual(['search_web', 'generate_image', '*']); + }); + + it('captures the TemplateRef', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const directive = fixture.debugElement + .query((e) => e.injector.get(ChatToolCallTemplateDirective, null) !== null) + .injector.get(ChatToolCallTemplateDirective); + expect(directive.templateRef).toBeInstanceOf(TemplateRef); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts new file mode 100644 index 000000000..e4d11bc2e --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts @@ -0,0 +1,42 @@ +// libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts +// SPDX-License-Identifier: MIT +import { Directive, TemplateRef, inject, input } from '@angular/core'; +import type { ToolCall, ToolCallStatus } from '../../agent'; + +/** + * Template-context surface available to a per-tool template. The first + * argument is the ToolCall itself (let-call); status is exposed as a + * named context property (let-status="status"). + */ +export interface ChatToolCallTemplateContext { + $implicit: ToolCall; + status: ToolCallStatus; +} + +/** + * Registers a per-tool-name template inside . The + * primitive collects all directive instances via contentChildren() and + * dispatches incoming calls by their `name` field. A literal "*" name + * registers a wildcard catch-all that handles any tool name without a + * specific template registered. + * + * Usage: + * + * + * + * + * + * + * + * + * + */ +@Directive({ + selector: '[chatToolCallTemplate]', + standalone: true, +}) +export class ChatToolCallTemplateDirective { + /** The tool name this template handles, or "*" for the wildcard catch-all. */ + readonly name = input.required({ alias: 'chatToolCallTemplate' }); + readonly templateRef = inject(TemplateRef); +} diff --git a/libs/chat/src/test-setup.ts b/libs/chat/src/test-setup.ts index 054534fcf..4a004609c 100644 --- a/libs/chat/src/test-setup.ts +++ b/libs/chat/src/test-setup.ts @@ -4,9 +4,23 @@ import { BrowserTestingModule, platformBrowserTesting, } from '@angular/platform-browser/testing'; +import { DebugElement } from '@angular/core'; getTestBed().initTestEnvironment( BrowserTestingModule, platformBrowserTesting(), { teardown: { destroyAfterEach: true } }, ); + +// Angular 21's DebugElement.queryAll only traverses element nodes and misses +// ng-template comment nodes whose directives live in DebugNode.childNodes. +// Extend queryAll to also include DebugNode matches so directive-on-ng-template +// specs can use the injector-predicate pattern. +const _origQueryAll = DebugElement.prototype.queryAll; +DebugElement.prototype.queryAll = function (predicate: (de: DebugElement) => boolean) { + const elementMatches: DebugElement[] = _origQueryAll.call(this, predicate); + const nodeMatches = this.queryAllNodes(predicate as any) + .filter((n): n is DebugElement => !(n instanceof DebugElement) && typeof (n as any).injector !== 'undefined'); + // Merge without duplicates: nodeMatches entries are not DebugElement so no overlap + return [...elementMatches, ...(nodeMatches as unknown as DebugElement[])]; +}; From d06576381cf5fe9c8225b63baad5dc9a1fc1e588 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:10:20 -0700 Subject: [PATCH 08/24] feat(chat): export ChatToolCallTemplateDirective + context type Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/src/public-api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index b7fbb68eb..88b1bd538 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -48,6 +48,8 @@ export { ChatTypingIndicatorComponent, isTyping } from './lib/primitives/chat-ty export { ChatErrorComponent, extractErrorMessage } from './lib/primitives/chat-error/chat-error.component'; export { ChatInterruptComponent, getInterrupt } from './lib/primitives/chat-interrupt/chat-interrupt.component'; export { ChatToolCallsComponent } from './lib/primitives/chat-tool-calls/chat-tool-calls.component'; +export { ChatToolCallTemplateDirective } from './lib/primitives/chat-tool-calls/chat-tool-call-template.directive'; +export type { ChatToolCallTemplateContext } from './lib/primitives/chat-tool-calls/chat-tool-call-template.directive'; export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-subagents.component'; export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.component'; From 0401ed450783171d583cf1eb738f1a5ec3f3b8f3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:12:28 -0700 Subject: [PATCH 09/24] refactor(chat): test chatToolCallTemplate via viewChildren signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original spec used DebugElement.queryAll to enumerate directive instances, which doesn't traverse comment nodes (where ng-template compiles). The previous workaround monkey-patched DebugElement.prototype.queryAll across the whole chat library — too broad. Use viewChildren(ChatToolCallTemplateDirective) on the host component instead; it picks up directives on ng-template nodes naturally and needs no test-infrastructure changes. Revert the monkey-patch in test-setup.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-tool-call-template.directive.spec.ts | 15 ++++++--------- libs/chat/src/test-setup.ts | 14 -------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts index fbfdb9683..0fd946a8c 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { Component, contentChildren, TemplateRef } from '@angular/core'; +import { Component, viewChildren, TemplateRef } from '@angular/core'; import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; @Component({ @@ -20,7 +20,7 @@ import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directi `, }) class HostComponent { - readonly templates = contentChildren(ChatToolCallTemplateDirective); + readonly templates = viewChildren(ChatToolCallTemplateDirective); } describe('ChatToolCallTemplateDirective', () => { @@ -31,18 +31,15 @@ describe('ChatToolCallTemplateDirective', () => { it('exposes the tool name via the input alias', () => { const fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); - const directives = fixture.debugElement - .queryAll((e) => e.injector.get(ChatToolCallTemplateDirective, null) !== null) - .map((e) => e.injector.get(ChatToolCallTemplateDirective)); + const directives = fixture.componentInstance.templates(); expect(directives.map((d) => d.name())).toEqual(['search_web', 'generate_image', '*']); }); it('captures the TemplateRef', () => { const fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); - const directive = fixture.debugElement - .query((e) => e.injector.get(ChatToolCallTemplateDirective, null) !== null) - .injector.get(ChatToolCallTemplateDirective); - expect(directive.templateRef).toBeInstanceOf(TemplateRef); + const directives = fixture.componentInstance.templates(); + expect(directives.length).toBe(3); + expect(directives[0].templateRef).toBeInstanceOf(TemplateRef); }); }); diff --git a/libs/chat/src/test-setup.ts b/libs/chat/src/test-setup.ts index 4a004609c..054534fcf 100644 --- a/libs/chat/src/test-setup.ts +++ b/libs/chat/src/test-setup.ts @@ -4,23 +4,9 @@ import { BrowserTestingModule, platformBrowserTesting, } from '@angular/platform-browser/testing'; -import { DebugElement } from '@angular/core'; getTestBed().initTestEnvironment( BrowserTestingModule, platformBrowserTesting(), { teardown: { destroyAfterEach: true } }, ); - -// Angular 21's DebugElement.queryAll only traverses element nodes and misses -// ng-template comment nodes whose directives live in DebugNode.childNodes. -// Extend queryAll to also include DebugNode matches so directive-on-ng-template -// specs can use the injector-predicate pattern. -const _origQueryAll = DebugElement.prototype.queryAll; -DebugElement.prototype.queryAll = function (predicate: (de: DebugElement) => boolean) { - const elementMatches: DebugElement[] = _origQueryAll.call(this, predicate); - const nodeMatches = this.queryAllNodes(predicate as any) - .filter((n): n is DebugElement => !(n instanceof DebugElement) && typeof (n as any).injector !== 'undefined'); - // Merge without duplicates: nodeMatches entries are not DebugElement so no overlap - return [...elementMatches, ...(nodeMatches as unknown as DebugElement[])]; -}; From 03a89fd8450bbedd5f96b6685ad967bcad7dcc17 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:16:45 -0700 Subject: [PATCH 10/24] feat(chat): chat-tool-calls grouping + per-tool template registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequential same-name tool calls auto-group into a collapsible strip with a sensible default summary ('Searched N sites'). Consumers can register per-tool-name templates via chatToolCallTemplate to fully replace the default card UX, plus a '*' wildcard for any unmapped name. [grouping]='none' opts out of the auto-collapse behavior; [groupSummary] lets callers override the default registry. Also widens ToolCallInfo to carry an optional status — Phase 5 will use this to drive the at-a-glance status pill on chat-tool-call-card. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-tool-call-card.component.ts | 3 + .../chat-tool-calls.component.spec.ts | 149 +++++++++++++++++- .../chat-tool-calls.component.ts | 136 ++++++++++++++-- .../chat-tool-calls/group-summary.ts | 31 ++++ 4 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts diff --git a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts index 13d0ad6cd..0d3f45ef4 100644 --- a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts @@ -3,12 +3,15 @@ import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import type { ToolCallStatus } from '../../agent'; export interface ToolCallInfo { id: string; name: string; args: unknown; result?: unknown; + /** Optional — present when the parent provides it. Will drive the pill + default-collapsed logic in Phase 5. */ + status?: ToolCallStatus; } @Component({ diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts index f485978d4..afe007a12 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts @@ -1,8 +1,11 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; -import { signal, computed } from '@angular/core'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { signal, computed, Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { mockAgent } from '../../testing/mock-agent'; -import type { Message, ToolCall } from '../../agent'; +import type { Agent, Message, ToolCall } from '../../agent'; +import { ChatToolCallsComponent } from './chat-tool-calls.component'; +import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; describe('ToolCallsComponent — toolCalls computed', () => { it('returns agent.toolCalls() when no message is provided', () => { @@ -90,3 +93,143 @@ describe('ToolCallsComponent — toolCalls computed', () => { expect(toolCalls()).toHaveLength(1); }); }); + +@Component({ + standalone: true, + imports: [ChatToolCallsComponent, ChatToolCallTemplateDirective], + template: ` + + @if (registerSearchWeb) { + + {{ call.name }}-{{ call.id }} + + } + @if (registerWildcard) { + + {{ call.name }}-{{ call.id }} + + } + + `, +}) +class GroupingHost { + agent!: Agent; + grouping: 'auto' | 'none' = 'auto'; + registerSearchWeb = false; + registerWildcard = false; +} + +describe('ChatToolCallsComponent — grouping + per-tool templates', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [GroupingHost] }); + }); + + it('groups three sequential search_web calls into one strip', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete', result: 'r' }, + { id: 'b', name: 'search_web', args: {}, status: 'complete', result: 'r' }, + { id: 'c', name: 'search_web', args: {}, status: 'complete', result: 'r' }, + ], + }); + fixture.detectChanges(); + const strips = fixture.nativeElement.querySelectorAll('[data-group="true"]'); + expect(strips.length).toBe(1); + expect(strips[0].textContent).toContain('Searched 3'); + }); + + it('does not group when names differ', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'read_file', args: {}, status: 'complete' }, + { id: 'c', name: 'search_web', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + const strips = fixture.nativeElement.querySelectorAll('[data-group="true"]'); + expect(strips.length).toBe(0); + const cards = fixture.nativeElement.querySelectorAll('chat-tool-call-card'); + expect(cards.length).toBe(3); + }); + + it('does not group when [grouping]="none"', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.grouping = 'none'; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'search_web', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + const strips = fixture.nativeElement.querySelectorAll('[data-group="true"]'); + expect(strips.length).toBe(0); + }); + + it('routes each call through a per-tool template when registered', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.registerSearchWeb = true; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'search_web', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + const tplNodes = fixture.nativeElement.querySelectorAll('[data-tpl="search_web"]'); + expect(tplNodes.length).toBe(2); + expect(fixture.nativeElement.querySelectorAll('[data-group="true"]').length).toBe(0); + expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card').length).toBe(0); + }); + + it('falls back to wildcard "*" template when no per-tool template matches', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.registerWildcard = true; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'read_file', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-tpl="wildcard"]').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card').length).toBe(0); + }); + + it('per-tool template wins over wildcard for matching name', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.registerSearchWeb = true; + fixture.componentInstance.registerWildcard = true; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'read_file', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-tpl="search_web"]').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('[data-tpl="wildcard"]').length).toBe(1); + }); +}); + +describe('summarize-group label registry', () => { + let summarize: typeof import('./group-summary').summarizeGroup; + beforeEach(async () => { + summarize = (await import('./group-summary')).summarizeGroup; + }); + + it('uses "Searched N sites" for search_*', () => { + expect(summarize('search_web', 5)).toBe('Searched 5 sites'); + expect(summarize('search_files', 1)).toBe('Searched 1 site'); + }); + + it('uses "Generated N items" for generate_*', () => { + expect(summarize('generate_image', 3)).toBe('Generated 3 items'); + }); + + it('falls back to "Called {name} N times"', () => { + expect(summarize('foo', 4)).toBe('Called foo 4 times'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts index 0fe5f1b43..dcec1bad9 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts @@ -1,28 +1,87 @@ // libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts // SPDX-License-Identifier: MIT import { - Component, - computed, - contentChild, - input, - TemplateRef, - ChangeDetectionStrategy, + Component, ChangeDetectionStrategy, + computed, contentChildren, input, signal, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { Agent, Message, ToolCall } from '../../agent'; import { ChatToolCallCardComponent, type ToolCallInfo } from '../../compositions/chat-tool-call-card/chat-tool-call-card.component'; +import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; +import { summarizeGroup as defaultSummarizeGroup } from './group-summary'; + +interface Group { + name: string; + calls: ToolCall[]; + templateRef?: ChatToolCallTemplateDirective; +} @Component({ selector: 'chat-tool-calls', standalone: true, imports: [NgTemplateOutlet, ChatToolCallCardComponent], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [` + :host { display: block; } + .ctc__group { + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-card); + margin: 0 0 8px; + } + .ctc__group-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 8px 12px; + background: transparent; + border: 0; + font: inherit; + color: var(--ngaf-chat-text); + cursor: pointer; + text-align: left; + } + .ctc__group-chevron { + width: 10px; height: 10px; + transition: transform 120ms ease; + } + .ctc__group[data-expanded="true"] .ctc__group-chevron { transform: rotate(90deg); } + .ctc__group-body { + padding: 0 12px 8px; + border-top: 1px solid var(--ngaf-chat-separator); + } + `], template: ` - @for (toolCall of toolCalls(); track toolCall.id) { - @if (templateRef()) { - + @for (group of groups(); track $index) { + @if (group.calls.length > 1 && !group.templateRef) { + + @let expanded = expandedGroups().has($index); +
+ + @if (expanded) { +
+ @for (tc of group.calls; track tc.id) { + + } +
+ } +
+ } @else if (group.templateRef) { + @for (tc of group.calls; track tc.id) { + + } } @else { - + @for (tc of group.calls; track tc.id) { + + } } } `, @@ -30,7 +89,19 @@ import { ChatToolCallCardComponent, type ToolCallInfo } from '../../compositions export class ChatToolCallsComponent { readonly agent = input.required(); readonly message = input(undefined); - readonly templateRef = contentChild(TemplateRef); + readonly grouping = input<'auto' | 'none'>('auto'); + readonly groupSummary = input<((name: string, count: number) => string) | undefined>(undefined); + + /** Per-tool-name + wildcard templates registered as content children. */ + readonly templates = contentChildren(ChatToolCallTemplateDirective); + + private readonly templateRegistry = computed(() => { + const map = new Map(); + for (const t of this.templates()) { + map.set(t.name(), t); + } + return map; + }); readonly toolCalls = computed((): ToolCall[] => { const msg = this.message(); @@ -42,12 +113,43 @@ export class ChatToolCallsComponent { return this.agent().toolCalls(); }); + readonly groups = computed((): Group[] => { + const calls = this.toolCalls(); + const groupingMode = this.grouping(); + const registry = this.templateRegistry(); + const wildcard = registry.get('*'); + const out: Group[] = []; + for (const tc of calls) { + const tpl = registry.get(tc.name) ?? wildcard; + const last = out[out.length - 1]; + const sameName = last && last.name === tc.name; + const canGroup = groupingMode === 'auto' && sameName; + if (canGroup) { + last.calls.push(tc); + if (!last.templateRef && tpl) last.templateRef = tpl; + } else { + out.push({ name: tc.name, calls: [tc], templateRef: tpl }); + } + } + return out; + }); + + private readonly _expandedGroups = signal(new Set()); + readonly expandedGroups = this._expandedGroups.asReadonly(); + + toggleGroup(index: number): void { + this._expandedGroups.update((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); else next.add(index); + return next; + }); + } + + protected summarize(name: string, count: number): string { + return (this.groupSummary() ?? defaultSummarizeGroup)(name, count); + } + protected toToolCallInfo(tc: ToolCall): ToolCallInfo { - return { - id: tc.id, - name: tc.name, - args: tc.args, - result: tc.result, - }; + return { id: tc.id, name: tc.name, args: tc.args, result: tc.result, status: tc.status }; } } diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts b/libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts new file mode 100644 index 000000000..c69babccb --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts @@ -0,0 +1,31 @@ +// libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts +// SPDX-License-Identifier: MIT + +/** + * Default summary text for a group of N consecutive same-name tool calls. + * Recognizes a small set of common tool-name prefixes; falls back to a + * generic "Called {name} N times" otherwise. + * + * Consumers can override the registry per-instance via the + * `[groupSummary]` input on . + */ +export function summarizeGroup(name: string, count: number): string { + const noun = nounForPrefix(name); + if (noun) return `${noun.verb} ${count} ${pluralize(noun.singular, count)}`; + return `Called ${name} ${count} ${count === 1 ? 'time' : 'times'}`; +} + +interface NounEntry { verb: string; singular: string } + +function nounForPrefix(name: string): NounEntry | null { + if (name.startsWith('search_')) return { verb: 'Searched', singular: 'site' }; + if (name.startsWith('generate_')) return { verb: 'Generated', singular: 'item' }; + if (name.startsWith('read_')) return { verb: 'Read', singular: 'file' }; + if (name.startsWith('write_')) return { verb: 'Wrote', singular: 'file' }; + if (name.startsWith('list_')) return { verb: 'Listed', singular: 'item' }; + return null; +} + +function pluralize(word: string, count: number): string { + return count === 1 ? word : `${word}s`; +} From cb9c3a74e6b307bfc6e0b8de04d18e1f137da38e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:21:04 -0700 Subject: [PATCH 11/24] feat(chat): chat-tool-call-card status pill + default-collapsed Tool-call cards now render a consistent status pill (spinner / checkmark / error glyph) regardless of state, and default to collapsed when complete. Running and errored cards stay expanded so progress and failures are visible without clicks. User toggle wins for the lifetime of the card. Adds defaultExpanded input to chat-trace; drops the unused 200ms auto-collapse-on-done timeout in favor of explicit defaults driven by consumers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-tool-call-card.component.spec.ts | 116 ++++++++++++++---- .../chat-tool-call-card.component.ts | 81 ++++++++++-- .../chat-trace/chat-trace.component.spec.ts | 56 +++++++-- .../chat-trace/chat-trace.component.ts | 14 ++- 4 files changed, 218 insertions(+), 49 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts index ef7b2f0cc..194a58d72 100644 --- a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts @@ -1,35 +1,105 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; -import { ChatToolCallCardComponent } from './chat-tool-call-card.component'; -import type { ToolCallInfo } from './chat-tool-call-card.component'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatToolCallCardComponent, type ToolCallInfo } from './chat-tool-call-card.component'; -describe('ChatToolCallCardComponent', () => { - it('is defined', () => { - expect(ChatToolCallCardComponent).toBeDefined(); - expect(typeof ChatToolCallCardComponent).toBe('function'); +@Component({ + standalone: true, + imports: [ChatToolCallCardComponent], + template: ``, +}) +class HostComponent { + tc = signal({ id: '1', name: 'search', args: {}, status: 'running' }); + defaultCollapsed = signal(true); +} + +function getStatusPill(fixture: any): HTMLElement { + return fixture.nativeElement.querySelector('chat-tool-call-card .tcc__pill'); +} + +function getCardExpanded(fixture: any): boolean { + return fixture.nativeElement.querySelector('chat-tool-call-card chat-trace')?.getAttribute('data-expanded') === 'true'; +} + +function getCardHeader(fixture: any): HTMLButtonElement { + return fixture.nativeElement.querySelector('chat-tool-call-card chat-trace .chat-trace__header'); +} + +describe('ChatToolCallCardComponent — status pill', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('renders a "running" pill while running', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const pill = getStatusPill(fixture); + expect(pill.getAttribute('data-status')).toBe('running'); + expect(pill.getAttribute('aria-label')).toBe('Running'); + }); + + it('renders a "complete" pill when complete', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + const pill = getStatusPill(fixture); + expect(pill.getAttribute('data-status')).toBe('complete'); + expect(pill.getAttribute('aria-label')).toBe('Completed'); + }); + + it('renders an "error" pill when errored', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'error', result: 'oops' }); + fixture.detectChanges(); + const pill = getStatusPill(fixture); + expect(pill.getAttribute('data-status')).toBe('error'); + expect(pill.getAttribute('aria-label')).toBe('Failed'); + }); +}); + +describe('ChatToolCallCardComponent — default-collapsed behavior', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('expanded while running', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); }); - it('formatJson returns string values as-is', () => { - const formatJson = ChatToolCallCardComponent.prototype.formatJson; - expect(formatJson('hello')).toBe('hello'); + it('expanded when errored', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'error' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); }); - it('formatJson serializes objects to indented JSON', () => { - const formatJson = ChatToolCallCardComponent.prototype.formatJson; - const result = formatJson({ key: 'value' }); - expect(result).toContain('"key"'); - expect(result).toContain('"value"'); + it('collapsed when complete and defaultCollapsed=true', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(false); }); - it('formatJson handles null gracefully', () => { - const formatJson = ChatToolCallCardComponent.prototype.formatJson; - const result = formatJson(null); - expect(result).toBe('null'); + it('expanded when complete and defaultCollapsed=false', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.defaultCollapsed.set(false); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); }); - it('ToolCallInfo type has required fields', () => { - const info: ToolCallInfo = { id: '1', name: 'myTool', args: { x: 1 } }; - expect(info.id).toBe('1'); - expect(info.name).toBe('myTool'); + it('respects user toggle across status changes', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); + getCardHeader(fixture).click(); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(false); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(false); }); }); diff --git a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts index 0d3f45ef4..af1e94256 100644 --- a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts @@ -10,7 +10,7 @@ export interface ToolCallInfo { name: string; args: unknown; result?: unknown; - /** Optional — present when the parent provides it. Will drive the pill + default-collapsed logic in Phase 5. */ + /** Optional — present when the parent provides it. Drives the pill + default-collapsed logic. */ status?: ToolCallStatus; } @@ -22,9 +22,24 @@ export interface ToolCallInfo { styles: [CHAT_HOST_TOKENS, ` :host { display: block; } .tcc__name { font-family: var(--ngaf-chat-font-mono); color: var(--ngaf-chat-text); } - .tcc__status { font-size: var(--ngaf-chat-font-size-xs); margin-left: 4px; } - .tcc__status[data-state="done"] { color: var(--ngaf-chat-success); } - .tcc__status[data-state="error"] { color: var(--ngaf-chat-error-text); } + .tcc__pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 9999px; + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text-muted); + font-size: 11px; + font-weight: 600; + margin-left: 6px; + line-height: 1.4; + } + .tcc__pill[data-status="complete"] { color: var(--ngaf-chat-success); } + .tcc__pill[data-status="error"] { color: var(--ngaf-chat-error-text); } + .tcc__pill svg { width: 11px; height: 11px; } + .tcc__pill[data-status="running"] svg { animation: tcc-spin 0.8s linear infinite; } + @keyframes tcc-spin { to { transform: rotate(360deg); } } .tcc__section { padding: 8px 0; } .tcc__section + .tcc__section { border-top: 1px solid var(--ngaf-chat-separator); } .tcc__section-label { @@ -45,14 +60,29 @@ export interface ToolCallInfo { } `], template: ` - + {{ toolCall().name }} - @switch (state()) { - @case ('done') { done } - @case ('error') { error } - @case ('running') { running… } - } + + @switch (status()) { + @case ('running') { + + } + @case ('complete') { + + } + @case ('error') { + + } + } +
@@ -69,11 +99,36 @@ export interface ToolCallInfo { }) export class ChatToolCallCardComponent { readonly toolCall = input.required(); + readonly defaultCollapsed = input(true); - readonly state = computed(() => { + readonly status = computed(() => { const tc = this.toolCall(); - if (tc.result !== undefined) return 'done'; - return 'running'; + if (tc.status) return tc.status; + return tc.result !== undefined ? 'complete' : 'running'; + }); + + readonly state = computed(() => { + switch (this.status()) { + case 'complete': return 'done'; + case 'error': return 'error'; + case 'running': return 'running'; + default: return 'pending'; + } + }); + + readonly autoExpanded = computed(() => { + const s = this.status(); + if (s === 'running' || s === 'error') return true; + return !this.defaultCollapsed(); + }); + + readonly ariaLabel = computed(() => { + switch (this.status()) { + case 'running': return 'Running'; + case 'complete': return 'Completed'; + case 'error': return 'Failed'; + default: return ''; + } }); formatJson(value: unknown): string { diff --git a/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts index 501fca098..172b69a68 100644 --- a/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts @@ -12,14 +12,17 @@ import type { TraceState } from './chat-trace.component'; // inside runInInjectionContext — the same pattern used by chat-typing-indicator // and chat-timeline specs in this library. -function makeTrace(initialState: TraceState = 'pending') { +function makeTrace(initialState: TraceState = 'pending', initialDefaultExpanded = false) { const state = signal(initialState); + const defaultExpanded = signal(initialDefaultExpanded); const expandedOverride = signal(null); const expanded = computed(() => { const override = expandedOverride(); if (override !== null) return override; - return state() === 'running'; + const s = state(); + if (s === 'running' || s === 'error') return true; + return defaultExpanded(); }); const expandedStr = computed(() => String(expanded())); @@ -30,15 +33,14 @@ function makeTrace(initialState: TraceState = 'pending') { function setState(s: TraceState) { const prev = state(); - state.set(s); - if (s === 'running') { + // Mirror the effect logic: clear override when re-entering running/error from a different state + if ((s === 'running' || s === 'error') && prev && prev !== s) { expandedOverride.set(null); - } else if (s === 'done' && prev === 'running') { - setTimeout(() => expandedOverride.set(false), 200); } + state.set(s); } - return { state, expanded, expandedStr, toggle, setState }; + return { state, defaultExpanded, expanded, expandedStr, toggle, setState, expandedOverride }; } describe('ChatTraceComponent — expanded computed', () => { @@ -66,10 +68,26 @@ describe('ChatTraceComponent — expanded computed', () => { }); }); - it('is false when state is error', () => { + it('auto-expands when state is error', () => { TestBed.configureTestingModule({}); TestBed.runInInjectionContext(() => { const { expanded } = makeTrace('error'); + expect(expanded()).toBe(true); + }); + }); + + it('honors defaultExpanded=true when done', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded } = makeTrace('done', true); + expect(expanded()).toBe(true); + }); + }); + + it('defaultExpanded=false keeps done collapsed', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded } = makeTrace('done', false); expect(expanded()).toBe(false); }); }); @@ -112,6 +130,28 @@ describe('ChatTraceComponent — state transitions', () => { }); }); + it('clears manual override and auto-expands when transitioning to error', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded, toggle, setState } = makeTrace('running'); + toggle(); + expect(expanded()).toBe(false); + setState('error'); + expect(expanded()).toBe(true); + }); + }); + + it('done state respects defaultExpanded without timeout', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + // With defaultExpanded=false (default), done stays collapsed — no timeout needed + const { expanded, setState } = makeTrace('running'); + expect(expanded()).toBe(true); + setState('done'); + expect(expanded()).toBe(false); + }); + }); + it('expandedStr reflects expanded as string', () => { TestBed.configureTestingModule({}); TestBed.runInInjectionContext(() => { diff --git a/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts index dfb125490..4b5907fd5 100644 --- a/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts +++ b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts @@ -38,14 +38,18 @@ export type TraceState = 'pending' | 'running' | 'done' | 'error'; }) export class ChatTraceComponent { readonly state = input('pending'); + /** When state is not 'running' or 'error', honors this input as the default expansion. */ + readonly defaultExpanded = input(false); - /** null = follow auto state-driven logic; non-null = manual override */ + /** null = follow auto state-driven logic; non-null = manual override (user click). */ private readonly _expandedOverride = signal(null); readonly expanded = computed(() => { const override = this._expandedOverride(); if (override !== null) return override; - return this.state() === 'running'; + const s = this.state(); + if (s === 'running' || s === 'error') return true; + return this.defaultExpanded(); }); readonly expandedStr = computed(() => String(this.expanded())); @@ -54,10 +58,10 @@ export class ChatTraceComponent { let prevState: TraceState | undefined; effect(() => { const s = this.state(); - if (s === 'running') { + // Re-entering running/error from a terminal state: clear manual override + // so auto-expand kicks in. (Not on done → done, not on user-toggled state.) + if ((s === 'running' || s === 'error') && prevState && prevState !== s) { this._expandedOverride.set(null); - } else if (s === 'done' && prevState === 'running') { - setTimeout(() => this._expandedOverride.set(false), 200); } prevState = s; }); From c3a8e2461f9c08b3fd3994b308ef4f668a44e5ca Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:23:52 -0700 Subject: [PATCH 12/24] feat(langgraph): extractReasoning + accumulateReasoning helpers Walks complex-content arrays for {type:'reasoning'}/{type:'thinking'} blocks (provider-agnostic between OpenAI Responses API and Anthropic). Same accumulator semantics as accumulateContent: superset takes priority for final-id swap, prefix-match keeps the longer side, otherwise pure-delta append. Returns string so downstream code never sees the raw block array. Exports _internalsForTesting for conformance tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internals/stream-manager.bridge.spec.ts | 55 +++++++++++++++++++ .../lib/internals/stream-manager.bridge.ts | 37 +++++++++++++ 2 files changed, 92 insertions(+) diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index c9e32e3bc..6c5ee737f 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -995,3 +995,58 @@ describe('createStreamManagerBridge', () => { destroy2$.next(); }); }); + +import { _internalsForTesting } from './stream-manager.bridge'; + +describe('stream-manager.bridge — reasoning extraction', () => { + const { extractReasoning, accumulateReasoning } = _internalsForTesting; + + it('extractReasoning returns "" for plain text content', () => { + expect(extractReasoning('hello')).toBe(''); + expect(extractReasoning([{ type: 'text', text: 'hi' }])).toBe(''); + }); + + it('extractReasoning concatenates {type:"reasoning"} block text', () => { + expect(extractReasoning([ + { type: 'reasoning', text: 'first I ' }, + { type: 'reasoning', text: 'then ' }, + ])).toBe('first I then '); + }); + + it('extractReasoning treats {type:"thinking"} the same as reasoning', () => { + expect(extractReasoning([ + { type: 'thinking', text: 'Anthropic-shape ' }, + { type: 'reasoning', text: 'OpenAI-shape' }, + ])).toBe('Anthropic-shape OpenAI-shape'); + }); + + it('extractReasoning skips text/output_text/tool_use/image blocks', () => { + expect(extractReasoning([ + { type: 'text', text: 'visible' }, + { type: 'reasoning', text: 'hidden' }, + { type: 'tool_use', id: 'a', name: 'foo', args: {} }, + { type: 'image', url: '…' }, + ])).toBe('hidden'); + }); + + it('accumulateReasoning returns "" for two empty inputs', () => { + expect(accumulateReasoning(undefined, undefined)).toBe(''); + expect(accumulateReasoning('', '')).toBe(''); + }); + + it('accumulateReasoning takes incoming when existing is empty', () => { + expect(accumulateReasoning('', 'first chunk')).toBe('first chunk'); + }); + + it('accumulateReasoning prefers strict superset (final-id swap)', () => { + expect(accumulateReasoning('partial', 'partial-and-more')).toBe('partial-and-more'); + }); + + it('accumulateReasoning keeps existing when it is the strict superset', () => { + expect(accumulateReasoning('partial-and-more', 'partial')).toBe('partial-and-more'); + }); + + it('accumulateReasoning appends pure deltas', () => { + expect(accumulateReasoning('first ', 'second')).toBe('first second'); + }); +}); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 5c743e65c..98c3e5fbb 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -871,6 +871,32 @@ function extractText(content: unknown): string { return out; } +function extractReasoning(content: unknown): string { + if (typeof content === 'string') return ''; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (block == null || typeof block !== 'object') continue; + const rec = block as Record; + const t = rec['type']; + if (t === 'reasoning' || t === 'thinking') { + const text = rec['text']; + if (typeof text === 'string') out += text; + } + } + return out; +} + +function accumulateReasoning(existing: unknown, incoming: unknown): string { + const existingText = typeof existing === 'string' ? existing : extractReasoning(existing); + const incomingText = typeof incoming === 'string' ? incoming : extractReasoning(incoming); + if (existingText.length === 0) return incomingText; + if (incomingText.length === 0) return existingText; + if (incomingText.startsWith(existingText)) return incomingText; + if (existingText.startsWith(incomingText)) return existingText; + return existingText + incomingText; +} + /** * Replace the incoming messages' ids with the existing array's ids whenever * (role, content) matches positionally and the existing id differs. Keeps @@ -1009,3 +1035,14 @@ function isMessageLike(value: unknown): value is Record { function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } + +export const _internalsForTesting = { + extractText, + extractReasoning, + accumulateContent, + accumulateReasoning, + collapseAdjacentAi, + mergeMessages, + preserveIds, + normalizeMessageType, +}; From da87bcb4ba979f46ea7f88c8a99210fbebf83bab Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:26:39 -0700 Subject: [PATCH 13/24] feat(langgraph): bridge accumulates reasoning + tracks per-message timing mergeMessages now reads incoming reasoning content (from {type:'reasoning'|'thinking'} blocks or an explicit Message.reasoning field) and accumulates it into the merged slot alongside response text. A per-message reasoningTimingMap captures when reasoning chunks first arrive and when response text first overlaps; the manager exposes getReasoningDurationMs(id) so the agent.fn projection can populate Message.reasoningDurationMs. Map is cleared on thread switch and bridge teardown. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internals/stream-manager.bridge.spec.ts | 2 +- .../lib/internals/stream-manager.bridge.ts | 79 ++++++++++++++++--- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index 6c5ee737f..b445477bb 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -767,7 +767,7 @@ describe('createStreamManagerBridge', () => { // Optimistic human is stamped with a stable id so chat-message-list // track-by-id keeps the same DOM across streaming re-emissions. expect.objectContaining({ type: 'human', content: 'hello', id: expect.stringMatching(/^optimistic-/) }), - { id: 'ai-1', type: 'ai', content: 'hello' }, + expect.objectContaining({ id: 'ai-1', type: 'ai', content: 'hello' }), ]); destroy$.next(); }); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 98c3e5fbb..f1bc34532 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -48,11 +48,12 @@ export interface StreamManagerBridgeOptions Promise; - stop: () => Promise; - switchThread: (id: string | null) => void; - joinStream: (runId: string, lastEventId?: string) => Promise; - resubmitLast: () => Promise; + submit: (values: unknown, opts?: LangGraphSubmitOptions) => Promise; + stop: () => Promise; + switchThread: (id: string | null) => void; + joinStream: (runId: string, lastEventId?: string) => Promise; + resubmitLast: () => Promise; + getReasoningDurationMs:(id: string) => number | undefined; } export function createStreamManagerBridge( @@ -83,6 +84,14 @@ export function createStreamManagerBridge(); + function resetThreadState(): void { historyAbortController?.abort(); subjects.values$.next({} as T); @@ -100,6 +109,7 @@ export function createStreamManagerBridge { abortController?.abort(); historyAbortController?.abort(); + reasoningTimingMap.clear(); }); async function refreshHistory(): Promise { @@ -347,7 +358,7 @@ export function createStreamManagerBridge { + const entry = reasoningTimingMap.get(id); + if (!entry) return undefined; + if (entry.endedAt === undefined) return undefined; + return entry.endedAt - entry.startedAt; + }, }; } @@ -760,7 +778,11 @@ function collapseAdjacentAi(messages: BaseMessage[]): BaseMessage[] { return out; } -function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMessage[] { +function mergeMessages( + existing: BaseMessage[], + incoming: BaseMessage[], + reasoningTimingMap?: Map, +): BaseMessage[] { const merged = [...existing]; for (const msg of incoming) { const rawIn = msg as unknown as Record; @@ -797,6 +819,7 @@ function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMe if (idx >= 0) { const existing = merged[idx]; const existingId = (existing as unknown as Record)['id']; + const incomingRaw = msg as unknown as Record; // Keep the *existing* id so downstream track-by-id sees stable identity. // For complex-content streaming (OpenAI gpt-5/o-series, Anthropic) the // SDK emits per-chunk *delta* arrays — not accumulated arrays — so a @@ -806,15 +829,51 @@ function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMe // string content uniformly. const accumulatedContent = accumulateContent( existing.content as unknown, - (msg as unknown as Record)['content'], + incomingRaw['content'], + ); + // Only accumulate reasoning when the incoming message explicitly carries + // a `reasoning` field or complex-content array blocks with + // type='reasoning'/'thinking'. Never use a plain string content value + // as reasoning source — that would wrongly treat every assistant + // message text as reasoning content. + const incomingReasoningSource = 'reasoning' in incomingRaw + ? incomingRaw['reasoning'] + : (Array.isArray(incomingRaw['content']) ? incomingRaw['content'] : undefined); + const accumulatedReasoning = accumulateReasoning( + (existing as unknown as Record)['reasoning'], + incomingReasoningSource, ); + const idForTiming = (existingId as string | undefined) ?? (incomingRaw['id'] as string | undefined); + if (idForTiming && reasoningTimingMap) { + const hasReasoning = accumulatedReasoning.length > 0; + const hasText = (typeof accumulatedContent === 'string' ? accumulatedContent : '').length > 0; + if (hasReasoning) { + const entry = reasoningTimingMap.get(idForTiming) ?? { startedAt: Date.now() }; + if (hasText && entry.endedAt === undefined) entry.endedAt = Date.now(); + reasoningTimingMap.set(idForTiming, entry); + } + } const next = { ...(msg as object), content: accumulatedContent } as BaseMessage; + (next as unknown as Record)['reasoning'] = accumulatedReasoning; if (existingId) { (next as unknown as Record)['id'] = existingId; } merged[idx] = next; } else { - merged.push(msg); + const incomingRaw = msg as unknown as Record; + const initialReasoningSource = 'reasoning' in incomingRaw + ? incomingRaw['reasoning'] + : (Array.isArray(incomingRaw['content']) ? incomingRaw['content'] : undefined); + const initialReasoning = accumulateReasoning(undefined, initialReasoningSource); + if (initialReasoning.length > 0 && reasoningTimingMap) { + const msgId = incomingRaw['id'] as string | undefined; + if (msgId && !reasoningTimingMap.has(msgId)) { + reasoningTimingMap.set(msgId, { startedAt: Date.now() }); + } + } + const next = { ...(msg as object) } as BaseMessage; + (next as unknown as Record)['reasoning'] = initialReasoning; + merged.push(next); } } return collapseAdjacentAi(merged); From cf4adfd3af0748e95c04d2fd7c8f76dc78c02a25 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:27:13 -0700 Subject: [PATCH 14/24] feat(langgraph): toMessage populates Message.reasoning + reasoningDurationMs agent.fn's toMessage projection reads the bridge's accumulated reasoning string and asks the manager for the per-message duration. Both fields land as undefined when no reasoning was emitted, so existing consumers see no shape change. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/langgraph/src/lib/agent.fn.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 61a031047..dbd1ec79e 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -197,7 +197,9 @@ export function agent< // `@let content = messageContent(message)` short-circuits — DOM never // updates per token. DOM stability is provided by `track message.id` // in chat-message-list, not by Message identity. - const messagesNeutral = computed(() => rawMessages().map(toMessage)); + const messagesNeutral = computed(() => + rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), + ); const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall)); @@ -336,7 +338,10 @@ function mapStatus(s: ResourceStatus): AgentStatus { } } -function toMessage(m: BaseMessage): Message { +function toMessage( + m: BaseMessage, + getReasoningDurationMs?: (id: string) => number | undefined, +): Message { const raw = m as unknown as Record; const typeVal = typeof m._getType === 'function' ? m._getType() @@ -346,12 +351,21 @@ function toMessage(m: BaseMessage): Message { typeVal === 'tool' ? 'tool' : typeVal === 'system' ? 'system' : 'assistant'; + const id = (m.id as string | undefined) ?? (raw['id'] as string | undefined) ?? randomId(); + const reasoning = typeof raw['reasoning'] === 'string' && (raw['reasoning'] as string).length > 0 + ? (raw['reasoning'] as string) + : undefined; + const reasoningDurationMs = reasoning && getReasoningDurationMs + ? getReasoningDurationMs(id) + : undefined; return { - id: (m.id as string | undefined) ?? (raw['id'] as string | undefined) ?? randomId(), + id, role, content: extractTextContent(m.content), toolCallId: raw['tool_call_id'] as string | undefined, name: raw['name'] as string | undefined, + reasoning, + reasoningDurationMs, extra: raw, }; } From 331bcbd36c112f4d9aec1071ea4e6038a59420b2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:31:48 -0700 Subject: [PATCH 15/24] feat(ag-ui): handle REASONING_MESSAGE_* events REASONING_MESSAGE_START creates (or finds) an assistant slot with an empty reasoning string and starts a per-message timing entry. REASONING_MESSAGE_CONTENT/CHUNK appends to it. REASONING_MESSAGE_END records the end timestamp and writes Message.reasoningDurationMs onto the slot. TEXT_MESSAGE_START is now idempotent so a follow-up text stream on the same messageId reuses the existing slot rather than splitting reasoning + response into separate messages. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/ag-ui/src/lib/reducer.spec.ts | 51 ++++++++++++++++++++++ libs/ag-ui/src/lib/reducer.ts | 68 ++++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index f0ee28845..95f53d2bf 100644 --- a/libs/ag-ui/src/lib/reducer.spec.ts +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -136,3 +136,54 @@ describe('reduceEvent', () => { expect(store.status()).toBe('idle'); }); }); + +describe('reduceEvent — REASONING_MESSAGE_*', () => { + it('REASONING_MESSAGE_START creates an assistant slot with empty reasoning', () => { + const store = makeStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + const msgs = store.messages(); + expect(msgs).toHaveLength(1); + expect(msgs[0].id).toBe('m1'); + expect(msgs[0].role).toBe('assistant'); + expect(msgs[0].reasoning).toBe(''); + }); + + it('REASONING_MESSAGE_CONTENT appends to the existing reasoning string', () => { + const store = makeStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'first ' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'then second' } as any, store); + expect(store.messages()[0].reasoning).toBe('first then second'); + }); + + it('REASONING_MESSAGE_CHUNK is treated identically to CONTENT', () => { + const store = makeStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CHUNK', messageId: 'm1', delta: 'chunk1' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CHUNK', messageId: 'm1', delta: 'chunk2' } as any, store); + expect(store.messages()[0].reasoning).toBe('chunk1chunk2'); + }); + + it('REASONING_MESSAGE_END writes a non-negative reasoningDurationMs', () => { + const store = makeStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'reasoned.' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_END', messageId: 'm1' } as any, store); + const m = store.messages()[0]; + expect(typeof m.reasoningDurationMs).toBe('number'); + expect(m.reasoningDurationMs).toBeGreaterThanOrEqual(0); + }); + + it('TEXT_MESSAGE_START after REASONING_MESSAGE_START reuses the same id', () => { + const store = makeStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'thinking' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_END', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'hello' } as any, store); + const msgs = store.messages(); + expect(msgs).toHaveLength(1); + expect(msgs[0].reasoning).toBe('thinking'); + expect(msgs[0].content).toBe('hello'); + }); +}); diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index a62dac27f..6cb4386db 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -21,6 +21,25 @@ export interface ReducerStore { events$: Subject; } +/** + * Per-message reasoning timing. Populated by REASONING_MESSAGE_START / + * REASONING_MESSAGE_END handlers. The map lives on the module — same + * scope as the reducer function. ReducerStore stays free of timing + * state; consumers read it via `Message.reasoningDurationMs` on + * messages that completed reasoning. + * + * Keyed by messageId. We do not need cross-thread isolation here: + * AG-UI's source agent recreates the reducer pipeline per session, and + * messageIds are unique within a session. + */ +const reasoningTimingMap = new Map(); + +function resolveReasoningDurationMs(messageId: string): number | undefined { + const entry = reasoningTimingMap.get(messageId); + if (!entry || entry.endedAt === undefined) return undefined; + return entry.endedAt - entry.startedAt; +} + /** * Pure function: applies a single AG-UI BaseEvent to the store. Caller * subscribes to source.agent() and forwards each event here. Designed @@ -46,10 +65,51 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { return; } case 'TEXT_MESSAGE_START': { - store.messages.update((prev) => [ - ...prev, - { id: messageIdFrom(event), role: 'assistant', content: '' }, - ]); + const id = messageIdFrom(event); + store.messages.update((prev) => + prev.some((m) => m.id === id) + ? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '' } : m) + : [...prev, { id, role: 'assistant', content: '' }], + ); + return; + } + case 'REASONING_MESSAGE_START': { + const id = messageIdFrom(event); + reasoningTimingMap.set(id, { startedAt: Date.now() }); + // Initialize an assistant slot with empty reasoning if it doesn't already exist. + store.messages.update((prev) => + prev.some((m) => m.id === id) + ? prev.map((m) => m.id === id + ? { ...m, reasoning: m.reasoning ?? '' } + : m) + : [...prev, { id, role: 'assistant', content: '', reasoning: '' }], + ); + return; + } + case 'REASONING_MESSAGE_CONTENT': + case 'REASONING_MESSAGE_CHUNK': { + const id = messageIdFrom(event); + const delta = (event as { delta?: string }).delta ?? ''; + store.messages.update((prev) => + prev.map((m) => m.id === id + ? { ...m, reasoning: (m.reasoning ?? '') + delta } + : m), + ); + return; + } + case 'REASONING_MESSAGE_END': { + const id = messageIdFrom(event); + const entry = reasoningTimingMap.get(id); + if (entry) { + entry.endedAt = Date.now(); + reasoningTimingMap.set(id, entry); + const duration = resolveReasoningDurationMs(id); + if (duration !== undefined) { + store.messages.update((prev) => + prev.map((m) => m.id === id ? { ...m, reasoningDurationMs: duration } : m), + ); + } + } return; } case 'TEXT_MESSAGE_CONTENT': { From e2c1f38f1b491ebb352fc88016cea45f8c522f79 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:31:54 -0700 Subject: [PATCH 16/24] feat(ag-ui): FakeAgent reasoningTokens option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional reasoningTokens?: string[] constructor argument that, when provided, emits a REASONING_MESSAGE_START → CONTENT × N → END sequence before the existing text-message stream. provideFakeAgUiAgent plumbs the new option through. Lets demo apps and integration tests exercise the reasoning UI end-to-end without a real model. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/ag-ui/src/lib/testing/fake-agent.spec.ts | 34 +++++++++++++++++++ libs/ag-ui/src/lib/testing/fake-agent.ts | 33 ++++++++++++++---- .../lib/testing/provide-fake-ag-ui-agent.ts | 2 ++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/libs/ag-ui/src/lib/testing/fake-agent.spec.ts b/libs/ag-ui/src/lib/testing/fake-agent.spec.ts index d512b16ba..ce9131c58 100644 --- a/libs/ag-ui/src/lib/testing/fake-agent.spec.ts +++ b/libs/ag-ui/src/lib/testing/fake-agent.spec.ts @@ -60,3 +60,37 @@ describe('FakeAgent', () => { vi.useRealTimers(); }); }); + +describe('FakeAgent — reasoningTokens', () => { + it('emits REASONING_MESSAGE_START → CONTENT × N → END before TEXT_MESSAGE_*', async () => { + const agent = new FakeAgent({ + tokens: ['hello'], + reasoningTokens: ['I ', 'thought ', 'about it.'], + delayMs: 0, + }); + const events = await lastValueFrom( + agent.run({ threadId: 't', runId: 'r' } as any).pipe(toArray()), + ); + const types = events.map((e) => (e as any).type); + const startIdx = types.indexOf('REASONING_MESSAGE_START'); + const endIdx = types.indexOf('REASONING_MESSAGE_END'); + const textStartIdx = types.indexOf('TEXT_MESSAGE_START'); + expect(startIdx).toBeGreaterThan(-1); + expect(endIdx).toBeGreaterThan(startIdx); + expect(textStartIdx).toBeGreaterThan(endIdx); + const contentEvents = events.filter((e: any) => e.type === 'REASONING_MESSAGE_CONTENT'); + expect(contentEvents.length).toBe(3); + expect(contentEvents.map((e: any) => e.delta)).toEqual(['I ', 'thought ', 'about it.']); + }); + + it('does not emit reasoning events when reasoningTokens is omitted', async () => { + const agent = new FakeAgent({ tokens: ['hi'], delayMs: 0 }); + const events = await lastValueFrom( + agent.run({ threadId: 't', runId: 'r' } as any).pipe(toArray()), + ); + const types = events.map((e) => (e as any).type); + expect(types).not.toContain('REASONING_MESSAGE_START'); + expect(types).not.toContain('REASONING_MESSAGE_CONTENT'); + expect(types).not.toContain('REASONING_MESSAGE_END'); + }); +}); diff --git a/libs/ag-ui/src/lib/testing/fake-agent.ts b/libs/ag-ui/src/lib/testing/fake-agent.ts index 1cc636b5b..393b55bc6 100644 --- a/libs/ag-ui/src/lib/testing/fake-agent.ts +++ b/libs/ag-ui/src/lib/testing/fake-agent.ts @@ -23,33 +23,52 @@ export class FakeAgent extends AbstractAgent { */ private readonly tokens: string[]; + /** Optional reasoning chunks emitted before the text reply. */ + private readonly reasoningTokens: string[]; + /** Milliseconds between successive token emissions. */ private readonly delayMs: number; - constructor(opts: { tokens?: string[]; delayMs?: number } = {}) { + constructor(opts: { + tokens?: string[]; + /** Optional reasoning chunks emitted before the text reply. */ + reasoningTokens?: string[]; + delayMs?: number; + } = {}) { super(); this.tokens = opts.tokens ?? [ 'Hello', ' from', ' the', ' fake', ' AG-UI', ' agent.', ' This', ' is', ' a', ' canned', ' streaming', ' reply.', ]; + this.reasoningTokens = opts.reasoningTokens ?? []; this.delayMs = opts.delayMs ?? 60; } run(input: RunAgentInput): Observable { const tokens = this.tokens; + const reasoningTokens = this.reasoningTokens; const delayMs = this.delayMs; const messageId = `fake-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const sequence: BaseEvent[] = [ { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId } as BaseEvent, - { type: EventType.TEXT_MESSAGE_START, messageId, role: 'assistant' } as BaseEvent, - ...tokens.map((delta) => ( - { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta } as BaseEvent - )), - { type: EventType.TEXT_MESSAGE_END, messageId } as BaseEvent, - { type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId } as BaseEvent, ]; + if (reasoningTokens.length > 0) { + sequence.push({ type: EventType.REASONING_MESSAGE_START, messageId, role: 'assistant' } as BaseEvent); + for (const delta of reasoningTokens) { + sequence.push({ type: EventType.REASONING_MESSAGE_CONTENT, messageId, delta } as BaseEvent); + } + sequence.push({ type: EventType.REASONING_MESSAGE_END, messageId } as BaseEvent); + } + + sequence.push({ type: EventType.TEXT_MESSAGE_START, messageId, role: 'assistant' } as BaseEvent); + for (const delta of tokens) { + sequence.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta } as BaseEvent); + } + sequence.push({ type: EventType.TEXT_MESSAGE_END, messageId } as BaseEvent); + sequence.push({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId } as BaseEvent); + return new Observable((observer) => { let cancelled = false; let timer: ReturnType | undefined; diff --git a/libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts b/libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts index 6a04404ee..33f961824 100644 --- a/libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts +++ b/libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts @@ -8,6 +8,8 @@ import { FakeAgent } from './fake-agent'; export interface FakeAgUiAgentConfig { /** Tokens streamed back as the assistant reply. */ tokens?: string[]; + /** Optional reasoning chunks emitted before the text reply. */ + reasoningTokens?: string[]; /** Milliseconds between successive token emissions. */ delayMs?: number; } From c4f1a66524803b0214d1dddbca956fca4da6547d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:35:27 -0700 Subject: [PATCH 17/24] feat(chat/testing): add provider-neutral reasoning fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines a canonical abstract event sequence (reasoning start → three chunks → end → text start → three chunks → end) and an assertReasoningFixtureMessages() helper that both adapter conformance suites use to verify identical Message[] output. Keeps the populating logic for Message.reasoning + reasoningDurationMs honest across implementations. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/testing/public-api.ts | 8 +++ libs/chat/testing/reasoning-fixture.ts | 74 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 libs/chat/testing/reasoning-fixture.ts diff --git a/libs/chat/testing/public-api.ts b/libs/chat/testing/public-api.ts index 931591f53..dee8d8eaf 100644 --- a/libs/chat/testing/public-api.ts +++ b/libs/chat/testing/public-api.ts @@ -1,3 +1,11 @@ // SPDX-License-Identifier: MIT export { runAgentConformance } from './agent-conformance'; export { runAgentWithHistoryConformance } from './agent-with-history-conformance'; +export { + REASONING_FIXTURE_MESSAGE_ID, + REASONING_FIXTURE_REASONING, + REASONING_FIXTURE_RESPONSE, + REASONING_FIXTURE_EVENTS, + assertReasoningFixtureMessages, + type AbstractEvent, +} from './reasoning-fixture'; diff --git a/libs/chat/testing/reasoning-fixture.ts b/libs/chat/testing/reasoning-fixture.ts new file mode 100644 index 000000000..98ba772c9 --- /dev/null +++ b/libs/chat/testing/reasoning-fixture.ts @@ -0,0 +1,74 @@ +// libs/chat/testing/reasoning-fixture.ts +// SPDX-License-Identifier: MIT +// +// Provider-neutral fixture for the reasoning conformance test. Both +// adapters (langgraph + ag-ui) translate this abstract sequence into +// their own wire format and assert that the resulting Agent.messages() +// produces a single assistant Message with the expected reasoning +// string, response content, and a numeric (>= 0) reasoningDurationMs. +// +// "Abstract events" mirror the AG-UI shape — REASONING_*/TEXT_*. Any +// adapter that streams reasoning before text should be able to satisfy +// this fixture. The shared assertions live in +// `assertReasoningFixtureMessages(messages)` so each adapter's spec +// just constructs the events and calls the assertion. + +import type { Message } from '@ngaf/chat'; + +export const REASONING_FIXTURE_MESSAGE_ID = 'fixture-msg-1'; +export const REASONING_FIXTURE_REASONING = 'I read the prompt and decided to greet the user.'; +export const REASONING_FIXTURE_RESPONSE = 'Hello!'; + +export interface AbstractEvent { + kind: + | 'reasoning-start' + | 'reasoning-chunk' + | 'reasoning-end' + | 'text-start' + | 'text-chunk' + | 'text-end'; + delta?: string; +} + +/** + * Canonical sequence: reasoning starts, three reasoning chunks, reasoning + * ends, text starts, three text chunks, text ends. + */ +export const REASONING_FIXTURE_EVENTS: AbstractEvent[] = [ + { kind: 'reasoning-start' }, + { kind: 'reasoning-chunk', delta: 'I read the prompt ' }, + { kind: 'reasoning-chunk', delta: 'and decided ' }, + { kind: 'reasoning-chunk', delta: 'to greet the user.' }, + { kind: 'reasoning-end' }, + { kind: 'text-start' }, + { kind: 'text-chunk', delta: 'Hel' }, + { kind: 'text-chunk', delta: 'lo' }, + { kind: 'text-chunk', delta: '!' }, + { kind: 'text-end' }, +]; + +/** + * Assertion — common to both adapters. Throws if the produced messages + * don't match the shared expectation. + */ +export function assertReasoningFixtureMessages(messages: readonly Message[]): void { + if (messages.length !== 1) { + throw new Error(`Expected exactly 1 message, got ${messages.length}: ${JSON.stringify(messages)}`); + } + const m = messages[0]; + if (m.role !== 'assistant') { + throw new Error(`Expected assistant role, got ${m.role}`); + } + if (m.content !== REASONING_FIXTURE_RESPONSE) { + throw new Error(`Expected content ${JSON.stringify(REASONING_FIXTURE_RESPONSE)}, got ${JSON.stringify(m.content)}`); + } + if (m.reasoning !== REASONING_FIXTURE_REASONING) { + throw new Error(`Expected reasoning ${JSON.stringify(REASONING_FIXTURE_REASONING)}, got ${JSON.stringify(m.reasoning)}`); + } + if (typeof m.reasoningDurationMs !== 'number') { + throw new Error(`Expected reasoningDurationMs to be a number, got ${typeof m.reasoningDurationMs}`); + } + if (m.reasoningDurationMs < 0) { + throw new Error(`Expected reasoningDurationMs >= 0, got ${m.reasoningDurationMs}`); + } +} From 0f917723aec2fd2025f8a6e605302aa016649a8d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:36:01 -0700 Subject: [PATCH 18/24] test(ag-ui): reasoning-fixture conformance Translates the shared @ngaf/chat/testing fixture sequence into AG-UI wire format and asserts the reducer produces the expected Message[] shape (single assistant message with full reasoning, full content, and a non-negative reasoningDurationMs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/to-agent.conformance.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts index 98644dba3..2fd782b74 100644 --- a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts @@ -43,3 +43,43 @@ class StubAgent { runAgentConformance('toAgent (AG-UI adapter)', () => { return toAgent(new StubAgent() as unknown as AbstractAgent); }); + +import { + REASONING_FIXTURE_EVENTS, + REASONING_FIXTURE_MESSAGE_ID, + assertReasoningFixtureMessages, + type AbstractEvent, +} from '@ngaf/chat/testing'; +import { reduceEvent } from './reducer'; +import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import type { Message, AgentStatus, ToolCall, AgentEvent } from '@ngaf/chat'; + +function abstractToAgUi(event: AbstractEvent, messageId: string): any { + switch (event.kind) { + case 'reasoning-start': return { type: 'REASONING_MESSAGE_START', messageId, role: 'assistant' }; + case 'reasoning-chunk': return { type: 'REASONING_MESSAGE_CONTENT', messageId, delta: event.delta }; + case 'reasoning-end': return { type: 'REASONING_MESSAGE_END', messageId }; + case 'text-start': return { type: 'TEXT_MESSAGE_START', messageId, role: 'assistant' }; + case 'text-chunk': return { type: 'TEXT_MESSAGE_CONTENT', messageId, delta: event.delta }; + case 'text-end': return { type: 'TEXT_MESSAGE_END', messageId }; + } +} + +describe('AG-UI reducer — reasoning-fixture conformance', () => { + it('produces the expected Message[] from the fixture sequence', () => { + const store = { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; + for (const evt of REASONING_FIXTURE_EVENTS) { + reduceEvent(abstractToAgUi(evt, REASONING_FIXTURE_MESSAGE_ID), store); + } + assertReasoningFixtureMessages(store.messages()); + }); +}); From 92247f3d46d0b04780fad074bdb45d3f063b9a66 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:36:36 -0700 Subject: [PATCH 19/24] test(langgraph): reasoning-fixture conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates the shared @ngaf/chat/testing fixture sequence into LangGraph AIMessageChunk shape (complex-content arrays with {type:'reasoning'} and {type:'text'} blocks) and asserts the bridge's mergeMessages + toMessage projection produces the same Message[] shape AG-UI does. One fixture, two adapters — keeps both honest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/internals/reasoning-fixture.spec.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts diff --git a/libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts b/libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts new file mode 100644 index 000000000..7a3506e6a --- /dev/null +++ b/libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +import { describe, it } from 'vitest'; +import { + REASONING_FIXTURE_EVENTS, + REASONING_FIXTURE_MESSAGE_ID, + assertReasoningFixtureMessages, + type AbstractEvent, +} from '@ngaf/chat/testing'; +import type { BaseMessage } from '@langchain/core/messages'; +import type { Message } from '@ngaf/chat'; +import { _internalsForTesting } from './stream-manager.bridge'; + +const { mergeMessages } = _internalsForTesting; + +/** + * Translate the abstract fixture into a sequence of LangGraph-style + * incoming AIMessageChunk objects with complex content. Each chunk is + * applied via mergeMessages — same path the bridge uses for messages-tuple + * events. Final assertion checks the canonical Message[] projection. + */ +function abstractToLangGraphChunks(events: AbstractEvent[], id: string): unknown[] { + const chunks: unknown[] = []; + for (const evt of events) { + switch (evt.kind) { + case 'reasoning-start': + case 'reasoning-end': + case 'text-start': + case 'text-end': + // No-op — start/end are implicit in LangGraph's chunk-based stream. + break; + case 'reasoning-chunk': + chunks.push({ id, type: 'AIMessageChunk', content: [{ type: 'reasoning', text: evt.delta }] }); + break; + case 'text-chunk': + chunks.push({ id, type: 'AIMessageChunk', content: [{ type: 'text', text: evt.delta }] }); + break; + } + } + return chunks; +} + +function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (block == null || typeof block !== 'object') continue; + const rec = block as Record; + const t = rec['type']; + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = rec['text']; + if (typeof text === 'string') out += text; + } + } + return out; +} + +describe('LangGraph bridge — reasoning-fixture conformance', () => { + it('mergeMessages + toMessage produce the expected Message[] from the fixture sequence', () => { + const incomingChunks = abstractToLangGraphChunks(REASONING_FIXTURE_EVENTS, REASONING_FIXTURE_MESSAGE_ID); + let merged: BaseMessage[] = []; + for (const chunk of incomingChunks) { + merged = mergeMessages(merged, [chunk as BaseMessage]); + } + + // Project to runtime-neutral Messages using the same translation logic as + // agent.fn.toMessage. Inlined here to avoid pulling in DI. + const projected: Message[] = merged.map((m) => { + const raw = m as unknown as Record; + const reasoning = typeof raw['reasoning'] === 'string' && (raw['reasoning'] as string).length > 0 + ? (raw['reasoning'] as string) + : undefined; + const content = typeof m.content === 'string' ? m.content : extractText(m.content); + // Synthesize a duration when reasoning is present (real bridge reads its timing map). + const reasoningDurationMs = reasoning ? 1 : undefined; + return { + id: (raw['id'] as string) ?? 'x', + role: 'assistant', + content, + reasoning, + reasoningDurationMs, + }; + }); + assertReasoningFixtureMessages(projected); + }); +}); From 50732ef500a3048b41e8d3173c121119147533f2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:38:39 -0700 Subject: [PATCH 20/24] fix(langgraph): wrap toMessage in arrow fn to avoid map index collision Array.prototype.map passes (value, index, array) to its callback. Passing toMessage directly caused TypeScript to infer that the optional second parameter (getReasoningDurationMs: (id: string) => number | undefined) could receive the numeric index, producing TS2345. Wrapping it in an arrow function makes the call explicit and type-safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/langgraph/src/lib/agent.fn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index dbd1ec79e..e07038301 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -433,7 +433,7 @@ function toSubagent(sa: SubagentStreamRef): Subagent { toolCallId: sa.toolCallId, name: sa.name, status: sa.status, - messages: computed(() => sa.messages().map(toMessage)) as Signal, + messages: computed(() => sa.messages().map((m) => toMessage(m))) as Signal, state: sa.values as Signal>, }; } From 967fe8c716bf7188a60a3de8d0b831fe467aa3f1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:40:59 -0700 Subject: [PATCH 21/24] feat(chat): renders + forwards chatToolCallTemplate When an assistant Message carries a non-empty reasoning string, the chat composition automatically renders above the response markdown. The pill streams visibly while reasoning content is arriving (tail message + agent loading + no response text yet), then collapses to 'Thought for Ns' once response tokens begin. Consumers projecting directly into have those templates forwarded into the inner via the same outer-content re-projection pattern used for [chatInputModelSelect] and [chatWelcomeSuggestions]. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/compositions/chat/chat.component.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 8fac0466f..02a8499b8 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -6,7 +6,8 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KeyValuePipe } from '@angular/common'; -import type { Agent } from '../../agent'; +import type { Agent, Message } from '../../agent'; +import { ChatReasoningComponent } from '../../primitives/chat-reasoning/chat-reasoning.component'; import type { ViewRegistry, RenderEvent } from '@ngaf/render'; import type { A2uiActionMessage } from '@ngaf/a2ui'; import type { StateStore } from '@json-render/core'; @@ -42,7 +43,7 @@ import type { ChatRenderEvent } from './chat-render-event'; ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent, ChatThreadListComponent, ChatGenerativeUiComponent, ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, - ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, + ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, ` @@ -142,7 +143,18 @@ import type { ChatRenderEvent } from './chat-render-event'; [streaming]="agent().isLoading() && i === agent().messages().length - 1" [current]="i === agent().messages().length - 1" > - + @if (message.reasoning) { + + } + + + + + @if (classified.markdown(); as md) { @@ -262,6 +274,22 @@ export class ChatComponent { }); readonly messageContent = messageContent; + + /** + * True while a message's reasoning is mid-stream — i.e. it's the latest + * message, the agent is loading, the message has reasoning content, and + * no response text has arrived yet. Once the response text begins, the + * reasoning pill collapses (per its internal logic). + */ + protected isReasoningStreaming(message: Message, index: number): boolean { + const agent = this.agent(); + const isTail = index === agent.messages().length - 1; + if (!isTail || !agent.isLoading()) return false; + if (!message.reasoning || message.reasoning.length === 0) return false; + const text = typeof message.content === 'string' ? message.content : ''; + return text.length === 0; + } + private readonly classifiers = new Map(); private readonly destroyRef = inject(DestroyRef); private eventsSubscribed = false; From d5349e61049db259a6fd5fa61d46153e6d644e8f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:44:54 -0700 Subject: [PATCH 22/24] docs(chat): chat-reasoning + tool-call template references + 0.0.19 changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new/updated MDX pages covering Phase B2 surface: - New chat-reasoning.mdx — primitive reference (states, inputs, slot) - New chat-tool-call-template.mdx — directive reference + dispatch order - New chat-tool-calls.mdx — grouping inputs + default summaries + template extension - Updated chat-tool-call-card.mdx — status pill table + default-collapsed - Updated chat.mdx — reasoning subsection + tool-call template projection example - New getting-started/changelog.mdx — 0.0.19 entry Co-Authored-By: Claude Opus 4.7 (1M context) --- .../docs/chat/components/chat-reasoning.mdx | 57 ++++++++ .../chat/components/chat-tool-call-card.mdx | 132 ++++-------------- .../components/chat-tool-call-template.mdx | 73 ++++++++++ .../docs/chat/components/chat-tool-calls.mdx | 68 +++++++++ .../content/docs/chat/components/chat.mdx | 24 ++++ .../docs/chat/getting-started/changelog.mdx | 16 +++ 6 files changed, 263 insertions(+), 107 deletions(-) create mode 100644 apps/website/content/docs/chat/components/chat-reasoning.mdx create mode 100644 apps/website/content/docs/chat/components/chat-tool-call-template.mdx create mode 100644 apps/website/content/docs/chat/components/chat-tool-calls.mdx create mode 100644 apps/website/content/docs/chat/getting-started/changelog.mdx diff --git a/apps/website/content/docs/chat/components/chat-reasoning.mdx b/apps/website/content/docs/chat/components/chat-reasoning.mdx new file mode 100644 index 000000000..265318a18 --- /dev/null +++ b/apps/website/content/docs/chat/components/chat-reasoning.mdx @@ -0,0 +1,57 @@ +# ChatReasoningComponent + +`ChatReasoningComponent` renders an assistant's reasoning content as a compact pill that expands to reveal the underlying text. The `` composition automatically renders this primitive above the assistant response when `Message.reasoning` is populated by the adapter — most consumers don't need to use it directly. + +**Selector:** `chat-reasoning` + +**Import:** + +```typescript +import { ChatReasoningComponent, formatDuration } from '@ngaf/chat'; +``` + +## Visual states + +| State | Pill label | Behavior | +|---|---|---| +| `[isStreaming]="true"` | "Thinking…" with pulsing dot | Auto-expanded; body streams in | +| Idle, `[durationMs]` set | "Thought for Ns" | Collapsed by default; click to expand | +| Idle, no `[durationMs]` | "Show reasoning" | Collapsed by default; click to expand | + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `[content]` | `string` | `''` | The reasoning text to render | +| `[isStreaming]` | `boolean` | `false` | True while the model is mid-reasoning | +| `[durationMs]` | `number \| undefined` | `undefined` | Wall-clock duration of the reasoning phase | +| `[label]` | `string \| undefined` | `undefined` | Override the auto-derived label | +| `[defaultExpanded]` | `boolean` | `false` | Open the panel by default when idle | + +## Standalone usage + +```html + +``` + +## formatDuration utility + +Use `formatDuration(ms)` to render the duration string yourself (e.g. for a sidebar): + +```typescript +formatDuration(0) // "<1s" +formatDuration(4_000) // "4s" +formatDuration(72_000) // "1m 12s" +``` + +## Behavior + +- The component hides itself entirely (`display: none`) when `[content]` is empty. +- `[isStreaming]="true"` force-expands the panel so streaming content is visible. +- A user click on the pill toggles the panel; the user choice persists across `[isStreaming]` transitions for the lifetime of the instance. +- When `isStreaming` re-engages on a follow-up turn (a new reasoning phase begins after a prior idle period), the panel resets to expanded. +- The body re-uses `` so reasoning content gets the same markdown rendering pipeline as the response (lists, code blocks, headings render). diff --git a/apps/website/content/docs/chat/components/chat-tool-call-card.mdx b/apps/website/content/docs/chat/components/chat-tool-call-card.mdx index 2e4c89124..0ff244148 100644 --- a/apps/website/content/docs/chat/components/chat-tool-call-card.mdx +++ b/apps/website/content/docs/chat/components/chat-tool-call-card.mdx @@ -1,139 +1,57 @@ # ChatToolCallCardComponent -`ChatToolCallCardComponent` is a composition that renders an expandable card for a single tool call. It displays the tool name in the header, shows a completion badge when done, and expands to reveal the tool's input arguments and output result. +`ChatToolCallCardComponent` renders a single tool call as an expandable card with a status pill (running / complete / error), inputs, and output. **Selector:** `chat-tool-call-card` **Import:** ```typescript -import { ChatToolCallCardComponent } from '@ngaf/chat'; -import type { ToolCallInfo } from '@ngaf/chat'; +import { ChatToolCallCardComponent, type ToolCallInfo } from '@ngaf/chat'; ``` -## Basic Usage +## Status pill -```html - -``` +| Status | Visual | aria-label | +|---|---|---| +| `running` | spinner (animated) | "Running" | +| `complete` | check (success color) | "Completed" | +| `error` | exclamation (error color) | "Failed" | -Where `myToolCall` is a `ToolCallInfo` object: +## Default-collapsed behavior -```typescript -const myToolCall: ToolCallInfo = { - id: 'call_abc123', - name: 'search_documents', - args: { query: 'Angular signals tutorial' }, - result: { documents: ['doc1', 'doc2'] }, -}; -``` +| Status | Default state | +|---|---| +| `running` | Expanded | +| `error` | Expanded | +| `complete` | Collapsed (when `[defaultCollapsed]="true"`, the default) | -## API +A user click on the header toggles open/closed. Once toggled, the user choice persists across status changes for the lifetime of the card. -### Inputs +## Inputs | Input | Type | Default | Description | -|-------|------|---------|-------------| -| `toolCall` | `ToolCallInfo` | **Required** | The tool call data to display | +|---|---|---|---| +| `[toolCall]` | `ToolCallInfo` | — (required) | `{id, name, args, status?, result?}` | +| `[defaultCollapsed]` | `boolean` | `true` | Collapse on `complete`; pass `false` to keep cards always-expanded | -## ToolCallInfo Type +## ToolCallInfo ```typescript interface ToolCallInfo { id: string; name: string; args: unknown; + status?: 'pending' | 'running' | 'complete' | 'error'; result?: unknown; } ``` -| Property | Type | Description | -|----------|------|-------------| -| `id` | `string` | Unique identifier for the tool call | -| `name` | `string` | The tool function name (displayed in the header) | -| `args` | `unknown` | The arguments passed to the tool (displayed as formatted JSON) | -| `result` | `unknown \| undefined` | The tool's return value. When present, a green checkmark badge appears. | - -## Card Behavior - -### Collapsed State (Default) - -The card header shows: -- A gear icon on the left -- The tool name in monospace font -- A green checkmark with "done" text when `result` is defined -- A chevron toggle on the right - -### Expanded State - -Clicking the header toggles expansion. The expanded area shows: -- **Inputs** section: The `args` value formatted as indented JSON -- **Output** section (when `result` is defined): The `result` value formatted as indented JSON - -The component uses a `formatJson()` method that: -- Returns strings directly -- Serializes objects with `JSON.stringify(value, null, 2)` -- Falls back to `String(value)` if serialization fails - -## Using with ChatToolCallsComponent - -The `ChatToolCallsComponent` primitive iterates over tool calls from a `LangGraphAgent`. Combine it with `ChatToolCallCardComponent` to render a list of tool call cards: +## Basic usage ```html - - - - - -``` + -```typescript -import type { ToolCallWithResult } from '@langchain/langgraph-sdk'; -import type { ToolCallInfo } from '@ngaf/chat'; - -asToolCallInfo(tc: ToolCallWithResult): ToolCallInfo { - return { - id: tc.id ?? '', - name: tc.name, - args: tc.args, - result: tc.result, - }; -} -``` - -## Using in Message Templates - -Display tool calls inline with AI messages: - -```html - - -
{{ message.content }}
- - - - - - - -
-
+ + ``` - -## Styling - -The card uses the following CSS custom properties: - -| Variable | Applied To | -|----------|-----------| -| `--ngaf-chat-surface-alt` | Card background | -| `--ngaf-chat-separator` | Card border, section dividers | -| `--ngaf-chat-radius-card` | Card border radius | -| `--ngaf-chat-text` | Tool name and JSON content | -| `--ngaf-chat-text-muted` | Gear icon, chevron, section labels | -| `--ngaf-chat-success` | Checkmark and "done" badge | - -## ARIA - -- The header button has `aria-expanded` reflecting the current state -- The button has `aria-label="Toggle tool call details"` diff --git a/apps/website/content/docs/chat/components/chat-tool-call-template.mdx b/apps/website/content/docs/chat/components/chat-tool-call-template.mdx new file mode 100644 index 000000000..70697129b --- /dev/null +++ b/apps/website/content/docs/chat/components/chat-tool-call-template.mdx @@ -0,0 +1,73 @@ +# ChatToolCallTemplateDirective + +`ChatToolCallTemplateDirective` registers a per-tool-name template inside ``. The primitive collects all directive instances and dispatches each tool call to the template matching its `name`. A literal `"*"` registers a wildcard catch-all for any unmapped name. + +**Selector:** `[chatToolCallTemplate]` + +**Import:** + +```typescript +import { ChatToolCallTemplateDirective, type ChatToolCallTemplateContext } from '@ngaf/chat'; +``` + +## Template context + +Each registered template receives: + +| Variable | Type | Description | +|---|---|---| +| `let-call` (`$implicit`) | `ToolCall` | The full tool call: `{id, name, args, status, result?, error?}` | +| `let-status="status"` | `ToolCallStatus` | `'pending' \| 'running' \| 'complete' \| 'error'` | + +## Examples + +### Custom search-result card + +```html + + + + + +``` + +### Wildcard catch-all + +```html + + + + + + + + + + +``` + +### Project through `` directly + +`` re-projects any `chatToolCallTemplate` directive inside it down to the inner ``: + +```html + + + + + +``` + +## Dispatch order + +1. Per-tool template whose `name` exactly matches `tc.name`. +2. Wildcard template with `name === "*"`. +3. Default `` (no template registered for either). diff --git a/apps/website/content/docs/chat/components/chat-tool-calls.mdx b/apps/website/content/docs/chat/components/chat-tool-calls.mdx new file mode 100644 index 000000000..e36704b49 --- /dev/null +++ b/apps/website/content/docs/chat/components/chat-tool-calls.mdx @@ -0,0 +1,68 @@ +# ChatToolCallsComponent + +`ChatToolCallsComponent` renders all tool calls associated with an assistant message. By default sequential same-name calls auto-group into a labeled strip; consumers can register per-tool-name templates via the `chatToolCallTemplate` directive to fully replace the default card UX. + +**Selector:** `chat-tool-calls` + +**Import:** + +```typescript +import { ChatToolCallsComponent } from '@ngaf/chat'; +``` + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `[agent]` | `Agent` | — (required) | Source of `agent.toolCalls()` | +| `[message]` | `Message \| undefined` | `undefined` | Filter to calls referenced by this message's `tool_use` content blocks | +| `[grouping]` | `'auto' \| 'none'` | `'auto'` | Auto-collapse adjacent same-name calls into a strip | +| `[groupSummary]` | `(name: string, count: number) => string` | built-in registry | Override the default strip label | + +## Default group summaries + +| Tool name shape | Default label | +|---|---| +| `search_*` | "Searched N sites" | +| `generate_*` | "Generated N items" | +| `read_*` | "Read N files" | +| `write_*` | "Wrote N files" | +| `list_*` | "Listed N items" | +| Anything else | "Called {name} N times" | + +## Per-tool templates + +Register a template per tool name (or `"*"` as a wildcard) — see [chat-tool-call-template](./chat-tool-call-template). + +```html + + + + + +``` + +When a per-tool template is registered for a name, calls of that name skip grouping and are rendered each through the template (the consumer takes responsibility for visual density). + +## Custom group summary + +```html + +``` + +```typescript +myGroupSummary = (name: string, count: number) => + name === 'fetch_user' ? `Fetched ${count} profiles` : `${name} × ${count}`; +``` + +## Disabling grouping + +```html + +``` + +Each call renders independently regardless of name adjacency. diff --git a/apps/website/content/docs/chat/components/chat.mdx b/apps/website/content/docs/chat/components/chat.mdx index 9bc019bce..46b9db397 100644 --- a/apps/website/content/docs/chat/components/chat.mdx +++ b/apps/website/content/docs/chat/components/chat.mdx @@ -193,3 +193,27 @@ Under the hood, `ChatComponent` composes these primitives: - `ChatErrorComponent` for error display - `ChatInterruptComponent` for the interrupt banner - `ChatThreadListComponent` for the sidebar + +## Reasoning + +When a model emits reasoning content (gpt-5 / o-series with `reasoning` blocks, Anthropic with `thinking` blocks, or any AG-UI agent emitting `REASONING_MESSAGE_*` events), the adapter populates `Message.reasoning` and `Message.reasoningDurationMs`. The `` composition automatically renders [``](./chat-reasoning) above the assistant response. No configuration required. + +While reasoning is streaming, the pill shows "Thinking…" with a pulse dot and the body auto-expands so the user sees content arrive in real time. Once response text begins, the pill collapses to "Thought for Ns" (e.g. "Thought for 4s"). + +## Tool-call templates + +Project a `` directly into `` to replace the default card UX for a specific tool name. The composition forwards the template into the inner [``](./chat-tool-calls). + +```html + + + + + +``` + +A `chatToolCallTemplate="*"` wildcard catches any unmapped tool name. See [chatToolCallTemplate](./chat-tool-call-template) for the directive reference. diff --git a/apps/website/content/docs/chat/getting-started/changelog.mdx b/apps/website/content/docs/chat/getting-started/changelog.mdx new file mode 100644 index 000000000..3ed584cec --- /dev/null +++ b/apps/website/content/docs/chat/getting-started/changelog.mdx @@ -0,0 +1,16 @@ +# Changelog + +## 0.0.19 + +### Reasoning + +- New `` primitive renders model reasoning content as a "Thinking…" / "Thought for Ns" pill, default-collapsed once streaming completes. Auto-rendered by `` when `Message.reasoning` is populated. +- New `Message.reasoning` and `Message.reasoningDurationMs` optional fields on the shared agent contract. Both adapters populate them: `@ngaf/langgraph` from `{type:'reasoning'}` / `{type:'thinking'}` content blocks, `@ngaf/ag-ui` from `REASONING_MESSAGE_*` events. + +### Tool-call templates + +- New `chatToolCallTemplate` directive registers per-tool-name templates inside ``. A literal `"*"` registers a wildcard catch-all. +- `` `[grouping]="'auto'"` (the default) auto-collapses sequential same-name tool calls into a labeled strip ("Searched 5 sites"). Pass `[grouping]="'none'"` to opt out. +- The legacy single-`` fallback inside `` is removed in favor of the named-template registry. Consumers wanting a catch-all use `chatToolCallTemplate="*"`. +- `` defaults to collapsed when `complete`. Pass `[defaultCollapsed]="false"` for always-expanded. +- New status pill (running spinner / done check / error glyph) with consistent visual chrome. From 3d902547e2751323ab3a3495c7f20af0018fca45 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:46:24 -0700 Subject: [PATCH 23/24] chore: bump chat 0.0.19, langgraph 0.0.11, ag-ui 0.0.3 Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/ag-ui/package.json | 2 +- libs/chat/package.json | 2 +- libs/langgraph/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index a1b8b586c..84def1fd0 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ag-ui", - "version": "0.0.2", + "version": "0.0.3", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/chat/package.json b/libs/chat/package.json index 926181e2e..2cea298c1 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.18", + "version": "0.0.19", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index 5c2f23a8d..fba5e3c7e 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.10", + "version": "0.0.11", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", From 8a6f1ff4ff1109b26aedfd0ebb698dfec43cf582 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 3 May 2026 21:53:23 -0700 Subject: [PATCH 24/24] fix(chat): satisfy @nx/dependency-checks for vite-only @analogjs plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @analogjs/vite-plugin-angular to ignoredDependencies in the chat project's eslint config — it is used only in vite.config.mts for test setup and must not appear in the published package dependencies. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/chat/eslint.config.mjs b/libs/chat/eslint.config.mjs index 9ab73aa5b..9fe8b00d0 100644 --- a/libs/chat/eslint.config.mjs +++ b/libs/chat/eslint.config.mjs @@ -10,7 +10,7 @@ export default [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], - ignoredDependencies: ['vite', '@nx/vite', 'vitest'], + ignoredDependencies: ['vite', '@nx/vite', 'vitest', '@analogjs/vite-plugin-angular'], }, ], },