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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/website/content/docs/ag-ui/api/inject-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<chat>` 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: `<chat [agent]="agent" />`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChatPage {
protected readonly agent = injectAgent();
}
```

Pair it with `provideAgent()` at bootstrap to configure the agent endpoint:

```ts
Expand Down Expand Up @@ -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<string, unknown>` | Latest agent state projected as a plain object. |
| `interrupt()` | `AgentInterrupt \| undefined` | Current interrupt, when the backend pauses for human input. |
| `events$` | `Observable<AgentEvent>` | Runtime-neutral observable of transient events (`state_update` / `custom`). Subscribe for side-effects; not a signal. |
| `submit(input, opts?)` | `Promise<void>` | Submit a user message or resume payload. |
| `stop()` | `Promise<void>` | Abort the active run. |
| `regenerate(index)` | `Promise<void>` | Remove the assistant message at `index` and rerun from the preceding user message. |
Expand All @@ -60,7 +80,9 @@ const chat = injectAgent() as AgUiAgent;
chat.customEvents(); // Signal<CustomStreamEvent[]>
```

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<AgentEvent>` 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

Expand Down
16 changes: 16 additions & 0 deletions apps/website/content/docs/ag-ui/api/to-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion apps/website/content/docs/ag-ui/concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, string>` | 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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions apps/website/content/docs/ag-ui/guides/custom-events.mdx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.

<Callout type="info" title="Live a2ui rendering">
`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.
</Callout>

## Relation to Interrupts
Expand Down
74 changes: 74 additions & 0 deletions apps/website/content/docs/ag-ui/guides/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<BaseEvent> {
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<BaseEvent>((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).
4 changes: 3 additions & 1 deletion apps/website/content/docs/ag-ui/reference/event-mapping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Loading