You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: apps/website/content/docs/ag-ui/api/api-docs.json
+45-5Lines changed: 45 additions & 5 deletions
Original file line number
Diff line number
Diff line change
@@ -506,6 +506,38 @@
506
506
],
507
507
"examples": []
508
508
},
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.",
"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.",
"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.",
"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.",
"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.",
Copy file name to clipboardExpand all lines: apps/website/content/docs/ag-ui/api/fake-agent.mdx
+52-9Lines changed: 52 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,5 +1,5 @@
1
1
---
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.
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:
62
65
63
66
```ts
64
-
script?:readonly {
67
+
typeFakeAgentScript=readonly {
65
68
when:'initial'| { toolMessageFor:string };
66
69
events:readonlyBaseEvent[];
67
70
}[];
68
71
```
69
72
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.
71
74
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:
Copy file name to clipboardExpand all lines: apps/website/content/docs/ag-ui/api/provide-agent.mdx
+12Lines changed: 12 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -115,6 +115,18 @@ const support = injectAgent(SUPPORT); // the support agent
115
115
116
116
<Callouttype="warning"title="The ref-less injectAgent() resolves the last ref provided">
117
117
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.
118
130
</Callout>
119
131
120
132
With a single ref the alias is exact: one instance, one config evaluation, reachable both as `injectAgent(TRIP)` and as `injectAgent()`.
const support =injectAgent(SUPPORT); // AgUiAgent<SupportState>
171
171
```
172
172
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.
174
174
175
175
Use `provideFakeAgent()` when you need the UI to run without a backend:
Copy file name to clipboardExpand all lines: apps/website/content/docs/ag-ui/guides/fake-agent.mdx
+32-3Lines changed: 32 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,5 +1,5 @@
1
1
---
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.
3
3
---
4
4
5
5
# Fake Agent
@@ -97,11 +97,40 @@ That gives you the same `Agent` contract as `provideFakeAgent()`.
97
97
|`tokens`|`string[]`| A short canned greeting | Emitted as text deltas in order. |
98
98
|`reasoningTokens`|`string[]`|`[]`| Emitted before text deltas. |
99
99
|`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:
`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.
101
130
102
131
## What it does not do
103
132
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`.
105
134
106
135
It is deliberately small. Use it to keep UI work moving, not to validate backend behavior.
0 commit comments