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. 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/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/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': { 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; } 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()); + }); +}); 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'], }, ], }, 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/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; } 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 13d0ad6cd..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 @@ -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. Drives the pill + default-collapsed logic. */ + status?: ToolCallStatus; } @Component({ @@ -19,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 { @@ -42,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') { + + } + } +
@@ -66,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/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; 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..9349a04c3 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts @@ -0,0 +1,165 @@ +// 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('does not force-collapse when streaming ends (user-open persists past true → false)', () => { + const fixture = makeFixture(); + 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'); + }); + + 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'); + }); + + 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..d6c8a4f72 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts @@ -0,0 +1,111 @@ +// libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, + computed, effect, 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'; + }); + + 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()); + } +} 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..0fd946a8c --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, viewChildren, 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 = viewChildren(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.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 directives = fixture.componentInstance.templates(); + expect(directives.length).toBe(3); + expect(directives[0].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/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`; +} 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; }); 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; } +`; 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`; +} diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 1b3a5636f..88b1bd538 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'; @@ -47,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'; @@ -80,6 +83,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, 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}`); + } +} 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', }, }); 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": "*", diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 61a031047..e07038301 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, }; } @@ -419,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>, }; } 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); + }); +}); 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..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(); }); @@ -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..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); @@ -871,6 +930,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 +1094,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, +};