Skip to content

Commit 11506fb

Browse files
bloveclaude
andauthored
feat(ag-ui): scriptable provideFakeAgent() and a duplicate-AgentRef warning (#1053)
* feat(ag-ui): warn in development when several AgentRefs share an injector The ref form of provideAgent() aliases the shared AGENT token, so N refs at one injector level leave the ref-less injectAgent() pointing at the Nth with no signal. Each ref-form call now also contributes its debug name to an internal multi token; the first agent built at that level reads the list and, in development mode only, emits a single console.warn naming every ref and the one the bare injectAgent() resolves. Multi providers do not merge across injectors, so refs at different levels neither collide nor warn. Behavior is otherwise unchanged: each ref still gets its own agent and its own config evaluation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3f6d73f commit 11506fb

13 files changed

Lines changed: 577 additions & 38 deletions

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/api/provide-agent.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ const support = injectAgent(SUPPORT); // the support agent
115115

116116
<Callout type="warning" title="The ref-less injectAgent() resolves the last ref provided">
117117
The ref form also aliases the shared token so that the no-argument `injectAgent()` keeps working. That token can only point at one agent, so when several refs are provided at the same injector level the **last** `provideAgent(ref, …)` call wins. In the example above, a bare `injectAgent()` returns the support agent. Always inject by ref when an injector provides more than one agent.
118+
119+
Development builds do not leave this silent. The first time such an injector builds one of its agents, the adapter emits a single `console.warn` naming every ref registered at that level and the one the ref-less `injectAgent()` resolves:
120+
121+
```text
122+
[@threadplane/ag-ui] provideAgent() was called with more than one AgentRef at the
123+
same injector level (trip, support). The ref-less injectAgent() reads a single
124+
shared token, so it resolves the last ref provided (support) and the others are
125+
reachable only by ref. Inject by ref — injectAgent(ref) — when an injector
126+
provides more than one agent.
127+
```
128+
129+
The warning is development-only (`isDevMode()`), fires once per injector, and never changes what DI hands back: each ref keeps its own agent. Refs provided at different injector levels — one in the application config, another in a component's `providers` — do not collide and do not warn.
118130
</Callout>
119131

120132
With a single ref the alias is exact: one instance, one config evaluation, reachable both as `injectAgent(TRIP)` and as `injectAgent()`.

apps/website/content/docs/ag-ui/concepts/architecture.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ const trip = injectAgent(TRIP); // AgUiAgent<TripState>
170170
const support = injectAgent(SUPPORT); // AgUiAgent<SupportState>
171171
```
172172

173-
The ref form also aliases the shared token that the no-argument `injectAgent()` reads. That token can only point at one agent, so when several refs are provided at the same level the **last** `provideAgent(ref, …)` call wins — inject by ref whenever an injector provides more than one agent. See [provideAgent()](/docs/ag-ui/api/provide-agent) for the full rule.
173+
The ref form also aliases the shared token that the no-argument `injectAgent()` reads. That token can only point at one agent, so when several refs are provided at the same level the **last** `provideAgent(ref, …)` call wins — inject by ref whenever an injector provides more than one agent. Development builds emit a one-time `console.warn` naming the refs involved when an injector level registers more than one, so the aliasing is visible while you build. See [provideAgent()](/docs/ag-ui/api/provide-agent) for the full rule.
174174

175175
Use `provideFakeAgent()` when you need the UI to run without a backend:
176176

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

0 commit comments

Comments
 (0)