diff --git a/apps/website/content/docs/ag-ui/api/inject-agent.mdx b/apps/website/content/docs/ag-ui/api/inject-agent.mdx index cd437ff88..3413686bf 100644 --- a/apps/website/content/docs/ag-ui/api/inject-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/inject-agent.mdx @@ -12,6 +12,25 @@ readonly chat = injectAgent(); await this.chat.submit({ message: 'Hello' }); ``` +In practice you rarely call `submit()` yourself — you hand the agent to the `` composition from `@threadplane/chat`, which drives streaming, tool calls, errors, and submit for you: + +```ts +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChatComponent } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/ag-ui'; + +@Component({ + selector: 'app-chat', + standalone: true, + imports: [ChatComponent], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatPage { + protected readonly agent = injectAgent(); +} +``` + Pair it with `provideAgent()` at bootstrap to configure the agent endpoint: ```ts @@ -39,6 +58,7 @@ These fields are stable across runtime adapters and are what chat components con | `toolCalls()` | `ToolCall[]` | Tool calls projected into the chat contract. | | `state()` | `Record` | Latest agent state projected as a plain object. | | `interrupt()` | `AgentInterrupt \| undefined` | Current interrupt, when the backend pauses for human input. | +| `events$` | `Observable` | Runtime-neutral observable of transient events (`state_update` / `custom`). Subscribe for side-effects; not a signal. | | `submit(input, opts?)` | `Promise` | Submit a user message or resume payload. | | `stop()` | `Promise` | Abort the active run. | | `regenerate(index)` | `Promise` | Remove the assistant message at `index` and rerun from the preceding user message. | @@ -60,7 +80,9 @@ const chat = injectAgent() as AgUiAgent; chat.customEvents(); // Signal ``` -The chat a2ui bridge reads `customEvents` to light up live generative-UI streaming when your backend emits `a2ui-partial` events. See the [Custom Events guide](/docs/ag-ui/guides/custom-events) for backend wiring details. +The chat a2ui bridge reads `customEvents` to light up live generative-UI streaming when your backend emits `a2ui-partial` events. The consuming side is documented in chat's [A2UI overview](/docs/chat/a2ui/overview). See the [Custom Events guide](/docs/ag-ui/guides/custom-events) for backend wiring details. + +Don't confuse `customEvents()` with the neutral `events$` listed above. Each `CUSTOM` event is fanned out to **both**: `events$` is the runtime-neutral `Observable` you subscribe to for transient side-effects (telemetry, toasts), while `customEvents()` is the AG-UI-specific signal that accumulates `CustomStreamEvent[]` as a per-run snapshot for reactive rendering. See the [Event Mapping reference](/docs/ag-ui/reference/event-mapping#custom-events) for the full fan-out. ## Submit and resume diff --git a/apps/website/content/docs/ag-ui/api/to-agent.mdx b/apps/website/content/docs/ag-ui/api/to-agent.mdx index 651c4f8fd..beb6c46ac 100644 --- a/apps/website/content/docs/ag-ui/api/to-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/to-agent.mdx @@ -47,6 +47,22 @@ interface CustomStreamEvent { Custom events are surfaced from AG-UI `CUSTOM` protocol events whose `name` is not `on_interrupt`. The `on_interrupt` event is routed to the `interrupt` signal instead. +Read the accumulated events after a run by calling the signal — inside an `effect`, it re-runs as new events arrive: + +```ts +import { effect } from '@angular/core'; +import { HttpAgent } from '@ag-ui/client'; +import { toAgent } from '@threadplane/ag-ui'; + +const agent = toAgent(new HttpAgent({ url: '/api/agent' })); + +effect(() => { + for (const e of agent.customEvents()) { + console.log(e.name, e.data); + } +}); +``` + ## Lifecycle note The returned `AgUiAgent` does not manage its own lifetime. When using DI via `provideAgent()`, the provider's destroy hook handles cleanup. When calling `toAgent()` directly, treat the returned agent's lifecycle as tied to the `AbstractAgent` instance you constructed. diff --git a/apps/website/content/docs/ag-ui/concepts/architecture.mdx b/apps/website/content/docs/ag-ui/concepts/architecture.mdx index 140f4fdf4..bc1b782ec 100644 --- a/apps/website/content/docs/ag-ui/concepts/architecture.mdx +++ b/apps/website/content/docs/ag-ui/concepts/architecture.mdx @@ -104,6 +104,8 @@ surface rendering — token-by-token, as the backend streams `a2ui-partial` events — matching the LangGraph adapter. Without it, a2ui still renders from the final tool-call surface; with it, surfaces build up live. +The consuming side — how the chat composition turns these events into rendered surfaces — lives in chat's [A2UI overview](/docs/chat/a2ui/overview). + ## Provider choices Use `provideAgent()` when you have a real AG-UI HTTP endpoint. @@ -117,7 +119,34 @@ provideAgent({ }); ``` -The config maps directly to the AG-UI `HttpAgent` options currently exposed by this package: `url`, `agentId`, `threadId`, and `headers`. +The config maps to the AG-UI `HttpAgent` options exposed by this package, plus an optional telemetry sink: + +| Option | Type | Description | +|---|---|---| +| `url` | `string` | **Required.** AG-UI backend HTTP/SSE endpoint. | +| `agentId` | `string` | Optional. Identifies a specific agent on the backend. | +| `threadId` | `string` | Optional. Resume an existing conversation thread. | +| `headers` | `Record` | Optional. Custom request headers (auth, tracing). | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional. App-owned telemetry sink — opt-in, emits nothing unless supplied. See [`@threadplane/telemetry`](/docs/telemetry/getting-started/introduction). | + +### Factory config for route params and DI + +`provideAgent()` also accepts a `() => AgentConfig` factory. The factory runs inside an Angular injection context, so it can call `inject()` to read services or route params when it builds the config: + +```ts +import { inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { provideAgent } from '@threadplane/ag-ui'; + +providers: [ + provideAgent(() => ({ + url: '/api/agent', + threadId: inject(ActivatedRoute).snapshot.params['threadId'], + })), +]; +``` + +This is the cleanest way to derive `threadId` (or auth headers) from runtime state at construction time — see the threadId-recreation note below. `threadId` here is a plain string consumed once at construction — the provider does not accept an Angular Signal, and the adapter does not observe changes to it at runtime. The AG-UI protocol carries events, not snapshots, and defines no server-side endpoint for "fetch the messages of thread X". To move a user between threads with their prior conversation restored, you have two options: diff --git a/apps/website/content/docs/ag-ui/getting-started/installation.mdx b/apps/website/content/docs/ag-ui/getting-started/installation.mdx index d6e90070d..10d5b73a4 100644 --- a/apps/website/content/docs/ag-ui/getting-started/installation.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/installation.mdx @@ -51,6 +51,7 @@ export const appConfig: ApplicationConfig = { | `agentId` | `string` | Optional. Identifies a specific agent on the backend. | | `threadId` | `string` | Optional. Resume an existing conversation thread. | | `headers` | `Record` | Optional. Custom request headers (auth, tracing). | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional. App-owned telemetry sink — opt-in, emits nothing unless supplied. See [`@threadplane/telemetry`](/docs/telemetry/getting-started/introduction). | ## Use in a component diff --git a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx index 72dd3c823..4187e5882 100644 --- a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx @@ -76,6 +76,10 @@ For me, the runtime-neutral `Agent` contract is the whole payoff: your component So swapping backends is a one-line change in `app.config.ts`, and the component code stays the same: ```diff +- import { provideAgent } from '@threadplane/langgraph'; - providers: [provideAgent({ apiUrl: '...' })], // LangGraph ++ import { provideAgent } from '@threadplane/ag-ui'; + providers: [provideAgent({ url: '...' })], // AG-UI ``` + +These are two different `provideAgent` functions from two different packages — the import source changes along with the config key (`apiUrl` for LangGraph, `url` for AG-UI). It's not one symbol that accepts both. diff --git a/apps/website/content/docs/ag-ui/guides/custom-events.mdx b/apps/website/content/docs/ag-ui/guides/custom-events.mdx index 1fe6be66b..4a1a8a214 100644 --- a/apps/website/content/docs/ag-ui/guides/custom-events.mdx +++ b/apps/website/content/docs/ag-ui/guides/custom-events.mdx @@ -1,6 +1,6 @@ # Custom Events -AG-UI `CUSTOM` events let a backend node push arbitrary data to the Angular client while a run is in progress. The adapter accumulates these events into the `customEvents` signal on the `AgUiAgent` returned by `injectAgent()`. +AG-UI `CUSTOM` events let a backend node push arbitrary data to the Angular client while a run is in progress. The adapter accumulates these events into a `customEvents` signal. That signal lives on the widened `AgUiAgent` type — `injectAgent()` returns the neutral `Agent`, so you cast its result to `AgUiAgent` to reach `customEvents` (shown in [Reading Custom Events](#reading-custom-events-in-angular) below). ```ts interface CustomStreamEvent { @@ -122,7 +122,7 @@ export class AnalysisComponent { Both patterns are zoneless-safe: Angular's signal graph tracks the `customEvents()` read and re-evaluates the derived value automatically. -`customEvents` is the mechanism the chat composition uses for progressive a2ui surface updates — partial argument events accumulate here during a tool call and drive live rendering before the call completes. If you are building a custom a2ui integration over AG-UI, read `agent.customEvents()` the same way. +`customEvents` is the mechanism the chat composition uses for progressive a2ui surface updates — partial argument events accumulate here during a tool call and drive live rendering before the call completes. The consuming side is documented in chat's [A2UI overview](/docs/chat/a2ui/overview). If you are building a custom a2ui integration over AG-UI, read `agent.customEvents()` the same way. ## Relation to Interrupts diff --git a/apps/website/content/docs/ag-ui/guides/testing.mdx b/apps/website/content/docs/ag-ui/guides/testing.mdx index 252cefeab..1d9ee0874 100644 --- a/apps/website/content/docs/ag-ui/guides/testing.mdx +++ b/apps/website/content/docs/ag-ui/guides/testing.mdx @@ -59,6 +59,32 @@ The component is byte-identical to production — only the provider changes. `Fa For the underlying `FakeAgent` class and its canned event sequence, see [Fake Agent](/docs/ag-ui/guides/fake-agent). +### A runnable spec + +Set `delayMs: 0` to collapse the inter-token delay, drive `submit()`, then assert the streamed assistant content shows up on `agent.messages()`. Awaiting `submit()` is what guarantees the run finished — `runAgent()` resolves once the canned stream completes: + +```typescript +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideFakeAgent, injectAgent } from '@threadplane/ag-ui'; + +describe('chat via provideFakeAgent', () => { + it('streams the canned tokens onto the agent', async () => { + TestBed.configureTestingModule({ + providers: [provideFakeAgent({ tokens: ['Hello'], delayMs: 0 })], + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + await agent.submit({ message: 'hi' }); + + const assistant = agent.messages().find((m) => m.role === 'assistant'); + expect(assistant?.content).toBe('Hello'); + }); +}); +``` + +`injectAgent()` needs an injection context, so resolve it through `TestBed.runInInjectionContext()`. The `RUN_FINISHED` event has reduced by the time the awaited `submit()` resolves, so no fake-timer flushing is required. + ## Contract mock: `mockAgent()` For component and unit tests where you don't need a streaming pipeline at all, use the neutral `mockAgent(initial)` from `@threadplane/chat`. It returns an `Agent` whose state surface is exposed as **writable signals** — set state directly and assert your component reacts. Nothing is real. @@ -74,3 +100,51 @@ m.messages.set([{ role: 'assistant', content: 'Hello!' }]); expect(m.messages()[0].content).toBe('Hello!'); expect(m.status()).toBe('running'); ``` + +## Testing tool calls, state, and custom events + +`FakeAgent` only scripts `RUN_*`, `REASONING_MESSAGE_*`, and `TEXT_MESSAGE_*` events — it never emits `TOOL_CALL_*`, `STATE_SNAPSHOT`/`STATE_DELTA`, or `CUSTOM`. To exercise the reducer's headline non-text features — tool-call rendering, shared state, citations, custom events — script your own `AbstractAgent` and feed it through `toAgent()`. The adapter reduces your scripted events into `toolCalls()`, `state()`, and `customEvents()` exactly as it would real wire events. + +```typescript +import { describe, it, expect } from 'vitest'; +import { + AbstractAgent, + EventType, + type BaseEvent, + type RunAgentInput, +} from '@ag-ui/client'; +import { Observable } from 'rxjs'; +import { toAgent } from '@threadplane/ag-ui'; + +/** Emits a scripted tool-call + custom-event sequence, no backend. */ +class ScriptedAgent extends AbstractAgent { + run(input: RunAgentInput): Observable { + const events: BaseEvent[] = [ + { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId } as BaseEvent, + { type: EventType.TOOL_CALL_START, toolCallId: 'search-1', toolCallName: 'search' } as BaseEvent, + { type: EventType.TOOL_CALL_ARGS, toolCallId: 'search-1', delta: '{"q":"Angular"}' } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: 'search-1' } as BaseEvent, + { type: EventType.STATE_SNAPSHOT, snapshot: { topic: 'billing' } } as BaseEvent, + { type: EventType.CUSTOM, name: 'analysis_progress', value: { pct: 100 } } as BaseEvent, + { type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId } as BaseEvent, + ]; + return new Observable((observer) => { + for (const event of events) observer.next(event); + observer.complete(); + }); + } +} + +describe('scripted AG-UI events', () => { + it('reduces tool calls, state, and custom events', async () => { + const agent = toAgent(new ScriptedAgent()); + await agent.submit({ message: 'find docs' }); + + expect(agent.toolCalls()[0]).toMatchObject({ name: 'search', args: { q: 'Angular' } }); + expect(agent.state()).toMatchObject({ topic: 'billing' }); + expect(agent.customEvents()).toContainEqual({ name: 'analysis_progress', data: { pct: 100 } }); + }); +}); +``` + +`customEvents()` is the AG-UI-specific signal — `toAgent()` returns an `AgUiAgent`, so it's reachable directly here without a cast. A `CUSTOM` event named `on_interrupt` would instead populate `agent.interrupt()`; see the [Interrupts guide](/docs/ag-ui/guides/interrupts). diff --git a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx index 733b51c9b..dfcae1bba 100644 --- a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx +++ b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx @@ -157,6 +157,8 @@ For every other custom event name, it emits: Use `state` for durable UI state. Use `events$` for transient events, telemetry hooks, or UI side effects that should not be stored as conversation state. +Every non-`on_interrupt` `CUSTOM` event is fanned out to **two** surfaces, not one. Alongside the `events$` emission above, the reducer also appends `{ name, data }` to the AG-UI-specific `customEvents()` signal documented on [`injectAgent()`](/docs/ag-ui/api/inject-agent#ag-ui-specific-surface) and [`toAgent()`](/docs/ag-ui/api/to-agent). Reach for the signal when you want an accumulated per-run snapshot for reactive rendering (for example, `a2ui-partial` generative UI); reach for `events$` when you want a transient stream for side-effects or telemetry. + ## Submit and stop `agent.submit({ message })` performs three steps: @@ -171,4 +173,4 @@ If `message` is omitted, no user message is appended, but `runAgent()` still run ## Unsupported protocol areas -The current adapter does not implement customer-facing flows for interrupts, subagents, history, or time-travel. Unknown protocol events are ignored rather than treated as errors. +Beyond the `on_interrupt` -> `interrupt` mapping documented above (and its `submit({ resume })` reply), the adapter implements no richer interrupt UI flow — no subagents, history, or time-travel. Unknown protocol events are ignored rather than treated as errors.