Skip to content

Commit 5a81590

Browse files
bloveclaude
andcommitted
feat(ag-ui): let provideFakeAgent() take a FakeAgentScript
FakeAgent's script option was constructor-only and unreachable through provideFakeAgent(), which accepted the shared FakeAgentConfig from @threadplane/chat/testing (tokens, reasoningTokens, delayMs). provideFakeAgent() now takes AgUiFakeAgentConfig — that shared config plus the AG-UI-only script — and passes it to the constructor, so tool calls, state, custom events, and interrupts are all scriptable through DI. The script shape is exported as FakeAgentScript. The shared config type in libs/chat is untouched. Docs updated: the api and guide pages no longer say script is constructor-only or that provideFakeAgent() cannot emit TOOL_CALL_*, STATE_* or CUSTOM, and each carries a fence that runs. api-docs regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 14ee674 commit 5a81590

9 files changed

Lines changed: 444 additions & 34 deletions

File tree

apps/website/content/docs/ag-ui/api/api-docs.json

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,38 @@
506506
],
507507
"examples": []
508508
},
509+
{
510+
"name": "AgUiFakeAgentConfig",
511+
"kind": "interface",
512+
"description": "Config accepted by provideFakeAgent: the shared `FakeAgentConfig`\n(`tokens`, `reasoningTokens`, `delayMs`) plus the AG-UI-only `script`, which\nreplaces the canned token reply with raw AG-UI events.",
513+
"properties": [
514+
{
515+
"name": "delayMs",
516+
"type": "number",
517+
"description": "Milliseconds between successive token emissions.",
518+
"optional": true
519+
},
520+
{
521+
"name": "reasoningTokens",
522+
"type": "string[]",
523+
"description": "Optional reasoning chunks emitted before the text reply.",
524+
"optional": true
525+
},
526+
{
527+
"name": "script",
528+
"type": "FakeAgentScript",
529+
"description": "Deterministic event branches — see FakeAgentScript.",
530+
"optional": true
531+
},
532+
{
533+
"name": "tokens",
534+
"type": "string[]",
535+
"description": "Assistant reply, streamed token-by-token.",
536+
"optional": true
537+
}
538+
],
539+
"examples": []
540+
},
509541
{
510542
"name": "CustomStreamEvent",
511543
"kind": "interface",
@@ -546,6 +578,13 @@
546578
],
547579
"examples": []
548580
},
581+
{
582+
"name": "FakeAgentScript",
583+
"kind": "type",
584+
"description": "Deterministic event branches for FakeAgent, reachable through the\nconstructor and through `provideFakeAgent({ script })`.\n\nEach branch supplies a raw AG-UI event sequence — tool calls, state\nsnapshots, custom events, anything the protocol defines. `when: 'initial'`\nmatches a turn whose history carries no tool result; `{ toolMessageFor: id }`\nmatches the follow-up turn whose history carries a tool result for that tool\ncall id. The first matching branch wins, and its `events` are wrapped in\n`RUN_STARTED` / `RUN_FINISHED`. When no branch matches, the canned token\nreply is streamed instead.",
585+
"signature": "readonly { events: readonly BaseEvent[]; when: \"initial\" | { toolMessageFor: string } }[]",
586+
"examples": []
587+
},
549588
{
550589
"name": "bridgeCitationsState",
551590
"kind": "function",
@@ -590,7 +629,7 @@
590629
{
591630
"name": "provideAgent",
592631
"kind": "function",
593-
"description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services or route params.\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site.\n\n**Several agents at one injector level.** Each `provideAgent(ref, …)` call\nbuilds its own agent, so two (or more) refs may be provided side by side in a\nsingle `providers` array and `injectAgent(refA)` / `injectAgent(refB)` return\ndistinct agents. The ref-less `injectAgent()` resolves a single shared token,\nwhich can only point at one of them: when more than one ref is provided at\nthe same level the **last** call wins. Always inject by ref when an injector\nprovides more than one agent.",
632+
"description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services or route params.\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site.\n\n**Several agents at one injector level.** Each `provideAgent(ref, …)` call\nbuilds its own agent, so two (or more) refs may be provided side by side in a\nsingle `providers` array and `injectAgent(refA)` / `injectAgent(refB)` return\ndistinct agents. The ref-less `injectAgent()` resolves a single shared token,\nwhich can only point at one of them: when more than one ref is provided at\nthe same level the **last** call wins. Always inject by ref when an injector\nprovides more than one agent. Development builds emit a one-time\n`console.warn` naming the refs involved when an injector level registers more\nthan one, so the silent last-ref-wins aliasing is visible during development.",
594633
"signature": "provideAgent(ref: AgentRef<T>, configOrFactory: AgentConfig | () => AgentConfig): Provider[]",
595634
"params": [
596635
{
@@ -617,12 +656,12 @@
617656
{
618657
"name": "provideFakeAgent",
619658
"kind": "function",
620-
"description": "Registers an in-process FakeAgent under AGENT.\n\nUse for offline demos and development. Drop-in replacement for\nprovideAgent({ url }) when no real backend is available.",
621-
"signature": "provideFakeAgent(config: FakeAgentConfig): Provider[]",
659+
"description": "Registers an in-process FakeAgent under AGENT.\n\nUse for offline demos and development. Drop-in replacement for\nprovideAgent({ url }) when no real backend is available.\n\nPass `script` to stream exact AG-UI events instead of the canned token\nreply — the adapter reduces them into `toolCalls()`, `state()`,\n`customEvents()`, and `interrupt()` exactly as it would real wire events.",
660+
"signature": "provideFakeAgent(config: AgUiFakeAgentConfig): Provider[]",
622661
"params": [
623662
{
624663
"name": "config",
625-
"type": "FakeAgentConfig",
664+
"type": "AgUiFakeAgentConfig",
626665
"description": "",
627666
"optional": true
628667
}
@@ -632,7 +671,8 @@
632671
"description": ""
633672
},
634673
"examples": [
635-
"```ts\nTestBed.configureTestingModule({\n providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })],\n});\n```"
674+
"```ts\nTestBed.configureTestingModule({\n providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })],\n});\n```",
675+
"```ts\nTestBed.configureTestingModule({\n providers: [provideFakeAgent({\n delayMs: 0,\n script: [{\n when: 'initial',\n events: [\n { type: EventType.TOOL_CALL_START, toolCallId: 't1', toolCallName: 'get_weather' },\n { type: EventType.TOOL_CALL_ARGS, toolCallId: 't1', delta: '{\"city\":\"SF\"}' },\n { type: EventType.TOOL_CALL_END, toolCallId: 't1' },\n ] as BaseEvent[],\n }],\n })],\n});\n```"
636676
]
637677
},
638678
{

apps/website/content/docs/ag-ui/api/fake-agent.mdx

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: FakeAgent and provideFakeAgent() stream a canned AG-UI response in process, with an optional script of raw events for exact test streams.
2+
description: FakeAgent and provideFakeAgent() stream a canned AG-UI response in process, or an exact script of raw events for tool calls, state, and interrupts.
33
---
44

55
# FakeAgent
@@ -22,7 +22,7 @@ bootstrapApplication(AppComponent, {
2222
});
2323
```
2424

25-
Pass a `FakeAgentConfig` to customize the canned response:
25+
Pass an `AgUiFakeAgentConfig` to customize the canned response:
2626

2727
```ts
2828
provideFakeAgent({
@@ -31,13 +31,16 @@ provideFakeAgent({
3131
})
3232
```
3333

34-
### FakeAgentConfig
34+
### AgUiFakeAgentConfig
3535

3636
| Option | Type | Description |
3737
|--------|------|-------------|
3838
| `tokens` | `string[]` | Assistant reply streamed token-by-token. Defaults to a fixed placeholder message. |
3939
| `reasoningTokens` | `string[]` | Optional reasoning chunks emitted before the text reply. |
4040
| `delayMs` | `number` | Milliseconds between successive token emissions. Defaults to `60`. |
41+
| `script` | [`FakeAgentScript`](#script) | Raw AG-UI event branches that replace the canned reply. |
42+
43+
The first three options are the shared `FakeAgentConfig` that every adapter's `provideFakeAgent()` accepts. `script` is the AG-UI-specific addition.
4144

4245
## FakeAgent class
4346

@@ -58,20 +61,60 @@ const agent = toAgent(new FakeAgent({
5861

5962
### script
6063

61-
The constructor accepts a fourth option that `FakeAgentConfig` does not carry, so it is reachable only by constructing `FakeAgent` yourself:
64+
`FakeAgentScript` is an exported type. Both the constructor and `provideFakeAgent()` accept it:
6265

6366
```ts
64-
script?: readonly {
67+
type FakeAgentScript = readonly {
6568
when: 'initial' | { toolMessageFor: string };
6669
events: readonly BaseEvent[];
6770
}[];
6871
```
6972

70-
Each branch supplies a raw AG-UI event sequence for tests that need an exact stream — tool calls, `STATE_SNAPSHOT`, `CUSTOM` events, anything the protocol defines. `when: 'initial'` matches the first turn; `{ toolMessageFor: id }` matches the turn whose input carries a tool result for that tool call id. The first matching branch wins, and `FakeAgent` wraps its `events` in `RUN_STARTED` and `RUN_FINISHED` for you. When no branch matches, the canned token reply is emitted instead.
73+
Each branch supplies a raw AG-UI event sequence for tests that need an exact stream — tool calls, `STATE_SNAPSHOT`, `CUSTOM` events, anything the protocol defines. `when: 'initial'` matches a turn whose history carries no tool result; `{ toolMessageFor: id }` matches the follow-up turn whose history carries a tool result for that tool call id, which is what a resolved client tool produces. The first matching branch wins, and `FakeAgent` wraps its `events` in `RUN_STARTED` and `RUN_FINISHED` for you. When no branch matches, the canned token reply is emitted instead.
7174

72-
| Option | Type | Description |
73-
|--------|------|-------------|
74-
| `script` | `readonly { when: 'initial' \| { toolMessageFor: string }; events: readonly BaseEvent[] }[]` | Deterministic event branches. Constructor only — not part of `FakeAgentConfig`, so `provideFakeAgent()` cannot set it. |
75+
Through DI, a script reaches the reducer exactly as wire events would, so `toolCalls()`, `state()`, `customEvents()`, and `interrupt()` all populate:
76+
77+
```ts
78+
import { TestBed } from '@angular/core/testing';
79+
import { EventType, type BaseEvent } from '@ag-ui/client';
80+
import { provideFakeAgent, injectAgent } from '@threadplane/ag-ui';
81+
82+
it('reduces a scripted tool call', async () => {
83+
TestBed.configureTestingModule({
84+
providers: [
85+
provideFakeAgent({
86+
delayMs: 0,
87+
script: [
88+
{
89+
when: 'initial',
90+
events: [
91+
{
92+
type: EventType.TOOL_CALL_START,
93+
toolCallId: 'tool-1',
94+
toolCallName: 'get_weather',
95+
} as BaseEvent,
96+
{
97+
type: EventType.TOOL_CALL_ARGS,
98+
toolCallId: 'tool-1',
99+
delta: '{"city":"SF"}',
100+
} as BaseEvent,
101+
{ type: EventType.TOOL_CALL_END, toolCallId: 'tool-1' } as BaseEvent,
102+
],
103+
},
104+
],
105+
}),
106+
],
107+
});
108+
109+
const agent = TestBed.runInInjectionContext(() => injectAgent());
110+
await agent.submit({ message: 'weather?' });
111+
112+
expect(agent.toolCalls()[0]).toMatchObject({
113+
name: 'get_weather',
114+
args: { city: 'SF' },
115+
});
116+
});
117+
```
75118

76119
## TestBed example
77120

apps/website/content/docs/ag-ui/guides/fake-agent.mdx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Run the chat UI with no backend using provideFakeAgent(), what the canned stream contains, and when to construct FakeAgent with a script instead.
2+
description: Run the chat UI with no backend using provideFakeAgent(), what the canned stream contains, and how to script exact AG-UI events instead.
33
---
44

55
# Fake Agent
@@ -97,11 +97,40 @@ That gives you the same `Agent` contract as `provideFakeAgent()`.
9797
| `tokens` | `string[]` | A short canned greeting | Emitted as text deltas in order. |
9898
| `reasoningTokens` | `string[]` | `[]` | Emitted before text deltas. |
9999
| `delayMs` | `number` | `60` | Delay between events after the initial start delay. |
100-
| `script` | `readonly { when: 'initial' \| { toolMessageFor: string }; events: readonly BaseEvent[] }[]` | `[]` | Constructor only — not part of `FakeAgentConfig`, so `provideFakeAgent()` cannot set it. Supplies a raw AG-UI event sequence per branch, wrapped in `RUN_STARTED` / `RUN_FINISHED`. |
100+
| `script` | `FakeAgentScript` | `[]` | Raw AG-UI event branches that replace the canned reply, each wrapped in `RUN_STARTED` / `RUN_FINISHED`. Accepted by the constructor and by `provideFakeAgent()`. |
101+
102+
## Scripting exact events
103+
104+
Set `script` when the canned text reply is not enough — tool calls, shared state, custom events, and interrupts all reach the UI through it, with no backend and no direct construction:
105+
106+
```ts
107+
import { EventType, type BaseEvent } from '@ag-ui/client';
108+
import { provideFakeAgent } from '@threadplane/ag-ui';
109+
110+
providers: [
111+
provideFakeAgent({
112+
delayMs: 0,
113+
script: [
114+
{
115+
when: 'initial',
116+
events: [
117+
{
118+
type: EventType.CUSTOM,
119+
name: 'on_interrupt',
120+
value: { kind: 'approval', amount: 42 },
121+
} as BaseEvent,
122+
],
123+
},
124+
],
125+
}),
126+
]
127+
```
128+
129+
`when: 'initial'` matches a turn whose history carries no tool result. `{ toolMessageFor: 'tool-1' }` matches the follow-up turn whose history carries a tool result for `tool-1`, which is what resolving a client tool produces — so a two-branch script plays a tool call and then the reply that follows it. The first matching branch wins; when none matches, the canned token reply streams instead.
101130

102131
## What it does not do
103132

104-
`provideFakeAgent()` does not call a model, execute tools, persist history, or simulate interrupts. Constructed directly, `FakeAgent` accepts a `script` of raw AG-UI events for tests that need an exact stream, so tool calls, state, and interrupts are reachable that way.
133+
`provideFakeAgent()` does not call a model, execute tools, or persist history. It streams what you give it: a canned token reply by default, or the exact events in `script`.
105134

106135
It is deliberately small. Use it to keep UI work moving, not to validate backend behavior.
107136

apps/website/content/docs/ag-ui/guides/testing.mdx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Test AG-UI components with provideFakeAgent(), the neutral mockAgent(), or a scripted AbstractAgent, and know which double covers which surface.
2+
description: Test AG-UI components with provideFakeAgent() and its event script, the neutral mockAgent(), or a hand-written AbstractAgent, and know which double covers which surface.
33
---
44

55
# Testing
@@ -53,13 +53,14 @@ export class ChatHost {
5353
</Tab>
5454
</Tabs>
5555

56-
The component is byte-identical to production — only the provider changes. `FakeAgentConfig` (`{ tokens?, reasoningTokens?, delayMs? }`) lives in `@threadplane/chat/testing`:
56+
The component is byte-identical to production — only the provider changes. `provideFakeAgent()` accepts `AgUiFakeAgentConfig`: the shared `FakeAgentConfig` from `@threadplane/chat/testing` plus the AG-UI-only `script`.
5757

5858
| Option | Type | Notes |
5959
| --- | --- | --- |
6060
| `tokens` | `string[]` | Emitted as streamed text deltas, in order. |
6161
| `reasoningTokens` | `string[]` | Emitted before text deltas to exercise reasoning UI. |
6262
| `delayMs` | `number` | Delay between streamed events. |
63+
| `script` | `FakeAgentScript` | Raw AG-UI event branches that replace the canned reply. See [Testing tool calls, state, and custom events](#testing-tool-calls-state-and-custom-events). |
6364

6465
For the underlying `FakeAgent` class and its canned event sequence, see [Fake Agent](/docs/ag-ui/guides/fake-agent).
6566

@@ -109,7 +110,64 @@ expect(m.status()).toBe('running');
109110

110111
## Testing tool calls, state, and custom events
111112

112-
`provideFakeAgent()` only produces `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 — either pass a `script` of raw events to a directly-constructed `FakeAgent`, or 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.
113+
By default `provideFakeAgent()` produces only `RUN_*`, `REASONING_MESSAGE_*`, and `TEXT_MESSAGE_*` events. To exercise the reducer's headline non-text features — tool-call rendering, shared state, citations, custom events, interrupts — pass a `script` of raw events. It is part of the config `provideFakeAgent()` accepts, so the whole surface stays reachable through DI:
114+
115+
```typescript
116+
import { describe, it, expect } from 'vitest';
117+
import { TestBed } from '@angular/core/testing';
118+
import { EventType, type BaseEvent } from '@ag-ui/client';
119+
import { provideFakeAgent, injectAgent } from '@threadplane/ag-ui';
120+
121+
describe('scripted provideFakeAgent()', () => {
122+
it('reduces tool calls, state, and custom events', async () => {
123+
TestBed.configureTestingModule({
124+
providers: [
125+
provideFakeAgent({
126+
delayMs: 0,
127+
script: [
128+
{
129+
when: 'initial',
130+
events: [
131+
{
132+
type: EventType.TOOL_CALL_START,
133+
toolCallId: 'search-1',
134+
toolCallName: 'search',
135+
} as BaseEvent,
136+
{
137+
type: EventType.TOOL_CALL_ARGS,
138+
toolCallId: 'search-1',
139+
delta: '{"q":"Angular"}',
140+
} as BaseEvent,
141+
{ type: EventType.TOOL_CALL_END, toolCallId: 'search-1' } as BaseEvent,
142+
{
143+
type: EventType.STATE_SNAPSHOT,
144+
snapshot: { topic: 'billing' },
145+
} as BaseEvent,
146+
{
147+
type: EventType.CUSTOM,
148+
name: 'analysis_progress',
149+
value: { pct: 100 },
150+
} as BaseEvent,
151+
],
152+
},
153+
],
154+
}),
155+
],
156+
});
157+
158+
const agent = TestBed.runInInjectionContext(() => injectAgent());
159+
await agent.submit({ message: 'find docs' });
160+
161+
expect(agent.toolCalls()[0]).toMatchObject({ name: 'search', args: { q: 'Angular' } });
162+
expect(agent.state()).toMatchObject({ topic: 'billing' });
163+
expect(agent.customEvents()).toContainEqual({ name: 'analysis_progress', data: { pct: 100 } });
164+
});
165+
});
166+
```
167+
168+
A `CUSTOM` event named `on_interrupt` populates `agent.interrupt()` instead of `customEvents()`; see the [Interrupts guide](/docs/ag-ui/guides/interrupts). A second branch keyed `{ toolMessageFor: 'search-1' }` plays on the follow-up run that resolving a client tool starts, so an approve-then-continue turn is scriptable end to end.
169+
170+
When you need control the script does not give you — per-event timing, mid-stream errors, an observable you drive by hand — script your own `AbstractAgent` and feed it through `toAgent()`. The adapter reduces those events the same way.
113171

114172
```typescript
115173
import { describe, it, expect } from 'vitest';
@@ -153,4 +211,4 @@ describe('scripted AG-UI events', () => {
153211
});
154212
```
155213

156-
`customEvents()` is the AG-UI-specific signal — `toAgent()` returns an `AgUiAgent`, so it is 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).
214+
`customEvents()` is the AG-UI-specific signal — `toAgent()` returns an `AgUiAgent`, so it is reachable directly here without a cast, exactly as it is through `injectAgent()`.

0 commit comments

Comments
 (0)