From 14ee674168734813fd695401dbb9960ceff77207 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 13:49:24 -0700 Subject: [PATCH 1/2] 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 --- .../content/docs/ag-ui/api/provide-agent.mdx | 12 +++ .../docs/ag-ui/concepts/architecture.mdx | 2 +- ...rovide-agent.duplicate-ref-warning.spec.ts | 75 +++++++++++++++++++ libs/ag-ui/src/lib/provide-agent.ts | 48 +++++++++++- 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 libs/ag-ui/src/lib/provide-agent.duplicate-ref-warning.spec.ts diff --git a/apps/website/content/docs/ag-ui/api/provide-agent.mdx b/apps/website/content/docs/ag-ui/api/provide-agent.mdx index 6c0e6d5bb..68f4411e5 100644 --- a/apps/website/content/docs/ag-ui/api/provide-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/provide-agent.mdx @@ -115,6 +115,18 @@ const support = injectAgent(SUPPORT); // the support agent 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. + +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: + +```text +[@threadplane/ag-ui] provideAgent() was called with more than one AgentRef at the +same injector level (trip, support). The ref-less injectAgent() reads a single +shared token, so it resolves the last ref provided (support) and the others are +reachable only by ref. Inject by ref — injectAgent(ref) — when an injector +provides more than one agent. +``` + +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. With a single ref the alias is exact: one instance, one config evaluation, reachable both as `injectAgent(TRIP)` and as `injectAgent()`. diff --git a/apps/website/content/docs/ag-ui/concepts/architecture.mdx b/apps/website/content/docs/ag-ui/concepts/architecture.mdx index 5d3deea46..fb5ce67c6 100644 --- a/apps/website/content/docs/ag-ui/concepts/architecture.mdx +++ b/apps/website/content/docs/ag-ui/concepts/architecture.mdx @@ -170,7 +170,7 @@ const trip = injectAgent(TRIP); // AgUiAgent const support = injectAgent(SUPPORT); // AgUiAgent ``` -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. +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. Use `provideFakeAgent()` when you need the UI to run without a backend: diff --git a/libs/ag-ui/src/lib/provide-agent.duplicate-ref-warning.spec.ts b/libs/ag-ui/src/lib/provide-agent.duplicate-ref-warning.spec.ts new file mode 100644 index 000000000..3236cabde --- /dev/null +++ b/libs/ag-ui/src/lib/provide-agent.duplicate-ref-warning.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createAgentRef } from '@threadplane/chat'; +import { provideAgent, injectAgent } from './provide-agent'; + +afterEach(() => { + TestBed.resetTestingModule(); + vi.restoreAllMocks(); +}); + +describe('provideAgent — several refs at one injector level', () => { + it('warns once, naming both refs, and still hands out distinct agents', () => { + const REF_A = createAgentRef>('alpha-agent'); + const REF_B = createAgentRef>('beta-agent'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + TestBed.configureTestingModule({ + providers: [ + provideAgent(REF_A, { url: 'http://a.example/agent' }), + provideAgent(REF_B, { url: 'http://b.example/agent' }), + ], + }); + + const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A)); + const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B)); + + expect(agentA).not.toBe(agentB); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0][0]); + expect(message).toContain('alpha-agent'); + expect(message).toContain('beta-agent'); + expect(message).toContain('injectAgent()'); + // The bare token still resolves the LAST ref provided, as the warning says. + expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agentB); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('does not warn for a single ref at a level', () => { + const REF = createAgentRef>('only-agent'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + TestBed.configureTestingModule({ + providers: [provideAgent(REF, { url: 'http://single.example/agent' })], + }); + + TestBed.runInInjectionContext(() => injectAgent(REF)); + TestBed.runInInjectionContext(() => injectAgent()); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not warn when refs sit at different injector levels', () => { + const ROOT_REF = createAgentRef>('root-agent'); + const LEAF_REF = createAgentRef>('leaf-agent'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + @Component({ + template: '', + providers: [provideAgent(LEAF_REF, { url: 'http://leaf.example/agent' })], + }) + class LeafComponent { + readonly leaf = injectAgent(LEAF_REF); + readonly root = inject(ROOT_REF.token); + } + + TestBed.configureTestingModule({ + imports: [LeafComponent], + providers: [provideAgent(ROOT_REF, { url: 'http://root.example/agent' })], + }); + const fixture = TestBed.createComponent(LeafComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.leaf).not.toBe(fixture.componentInstance.root); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ag-ui/src/lib/provide-agent.ts b/libs/ag-ui/src/lib/provide-agent.ts index f98c81d90..03cba457d 100644 --- a/libs/ag-ui/src/lib/provide-agent.ts +++ b/libs/ag-ui/src/lib/provide-agent.ts @@ -1,4 +1,4 @@ -import { InjectionToken, inject, type Provider } from '@angular/core'; +import { InjectionToken, inject, isDevMode, type Provider } from '@angular/core'; import { HttpAgent } from '@ag-ui/client'; import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat'; import { toAgent, ɵtoAgentWithProtectedErrors, type AgUiAgent } from './to-agent'; @@ -62,6 +62,39 @@ function isAgentRef(x: unknown): x is AgentRef { return typeof x === 'object' && x !== null && 'token' in x; } +/** + * @internal — one entry per ref-form `provideAgent()` call, in registration + * order. Multi providers do not merge across injectors, so the resolved array + * names exactly the refs registered at the injector that resolves it. + */ +const AGENT_REF_DEBUG_NAMES = new InjectionToken('AG_UI_AGENT_REF_DEBUG_NAMES'); + +/** @internal — one warning per injector, not one per agent built there. */ +const warnedRefNameSets = new WeakSet(); + +/** + * @internal — development-only notice that the shared `AGENT` alias is + * ambiguous at this injector level. Must run inside an injection context. + */ +function warnOnAmbiguousSharedAlias(): void { + if (!isDevMode()) return; + const names = inject(AGENT_REF_DEBUG_NAMES, { optional: true }); + if (names === null || names.length < 2 || warnedRefNameSets.has(names)) return; + warnedRefNameSets.add(names); + console.warn( + `[@threadplane/ag-ui] provideAgent() was called with more than one AgentRef at the same ` + + `injector level (${names.join(', ')}). The ref-less injectAgent() reads a single shared ` + + `token, so it resolves the last ref provided (${names[names.length - 1]}) and the others ` + + `are reachable only by ref. Inject by ref — injectAgent(ref) — when an injector provides ` + + `more than one agent.`, + ); +} + +/** @internal — the name shown in the ambiguity warning. */ +function refDebugName(ref: AgentRef): string { + return String(ref.token).replace(/^InjectionToken\s+/, ''); +} + /** * Provides an Agent instance wired through HttpAgent and toAgent. * Constructs an HttpAgent from config and wraps it in the runtime-neutral @@ -83,7 +116,9 @@ function isAgentRef(x: unknown): x is AgentRef { * distinct agents. The ref-less `injectAgent()` resolves a single shared token, * which can only point at one of them: when more than one ref is provided at * the same level the **last** call wins. Always inject by ref when an injector - * provides more than one agent. + * 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 silent last-ref-wins aliasing is visible during development. * * @example Typed state via AgentRef * ```ts @@ -117,7 +152,14 @@ export function provideAgent>( // instance, one config evaluation); with several refs AGENT can only mean one // thing, so the last call wins. return [ - { provide: ref.token, useFactory: () => buildAgUiAgent(configOrFactory) }, + { provide: AGENT_REF_DEBUG_NAMES, multi: true, useValue: refDebugName(ref) }, + { + provide: ref.token, + useFactory: () => { + warnOnAmbiguousSharedAlias(); + return buildAgUiAgent(configOrFactory); + }, + }, { provide: AGENT, useExisting: ref.token }, ]; } From 5a81590aaf63f414fa50580a2babe6b27d766a0e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 13:49:32 -0700 Subject: [PATCH 2/2] feat(ag-ui): let provideFakeAgent() take a FakeAgentScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../content/docs/ag-ui/api/api-docs.json | 50 ++++- .../content/docs/ag-ui/api/fake-agent.mdx | 61 ++++++- .../content/docs/ag-ui/guides/fake-agent.mdx | 35 +++- .../content/docs/ag-ui/guides/testing.mdx | 66 ++++++- libs/ag-ui/src/lib/testing/fake-agent.ts | 28 ++- .../lib/testing/provide-fake-agent.spec.ts | 171 +++++++++++++++++- .../src/lib/testing/provide-fake-agent.ts | 35 +++- .../testing/provide-fake-agent.type-spec.ts | 30 +++ libs/ag-ui/src/public-api.ts | 2 + 9 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 libs/ag-ui/src/lib/testing/provide-fake-agent.type-spec.ts diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 79757f7df..5251103aa 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -506,6 +506,38 @@ ], "examples": [] }, + { + "name": "AgUiFakeAgentConfig", + "kind": "interface", + "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.", + "properties": [ + { + "name": "delayMs", + "type": "number", + "description": "Milliseconds between successive token emissions.", + "optional": true + }, + { + "name": "reasoningTokens", + "type": "string[]", + "description": "Optional reasoning chunks emitted before the text reply.", + "optional": true + }, + { + "name": "script", + "type": "FakeAgentScript", + "description": "Deterministic event branches — see FakeAgentScript.", + "optional": true + }, + { + "name": "tokens", + "type": "string[]", + "description": "Assistant reply, streamed token-by-token.", + "optional": true + } + ], + "examples": [] + }, { "name": "CustomStreamEvent", "kind": "interface", @@ -546,6 +578,13 @@ ], "examples": [] }, + { + "name": "FakeAgentScript", + "kind": "type", + "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.", + "signature": "readonly { events: readonly BaseEvent[]; when: \"initial\" | { toolMessageFor: string } }[]", + "examples": [] + }, { "name": "bridgeCitationsState", "kind": "function", @@ -590,7 +629,7 @@ { "name": "provideAgent", "kind": "function", - "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.", + "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.", "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ { @@ -617,12 +656,12 @@ { "name": "provideFakeAgent", "kind": "function", - "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.", - "signature": "provideFakeAgent(config: FakeAgentConfig): Provider[]", + "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.", + "signature": "provideFakeAgent(config: AgUiFakeAgentConfig): Provider[]", "params": [ { "name": "config", - "type": "FakeAgentConfig", + "type": "AgUiFakeAgentConfig", "description": "", "optional": true } @@ -632,7 +671,8 @@ "description": "" }, "examples": [ - "```ts\nTestBed.configureTestingModule({\n providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })],\n});\n```" + "```ts\nTestBed.configureTestingModule({\n providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })],\n});\n```", + "```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```" ] }, { diff --git a/apps/website/content/docs/ag-ui/api/fake-agent.mdx b/apps/website/content/docs/ag-ui/api/fake-agent.mdx index b192eeeeb..13d6a8052 100644 --- a/apps/website/content/docs/ag-ui/api/fake-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/fake-agent.mdx @@ -1,5 +1,5 @@ --- -description: FakeAgent and provideFakeAgent() stream a canned AG-UI response in process, with an optional script of raw events for exact test streams. +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. --- # FakeAgent @@ -22,7 +22,7 @@ bootstrapApplication(AppComponent, { }); ``` -Pass a `FakeAgentConfig` to customize the canned response: +Pass an `AgUiFakeAgentConfig` to customize the canned response: ```ts provideFakeAgent({ @@ -31,13 +31,16 @@ provideFakeAgent({ }) ``` -### FakeAgentConfig +### AgUiFakeAgentConfig | Option | Type | Description | |--------|------|-------------| | `tokens` | `string[]` | Assistant reply streamed token-by-token. Defaults to a fixed placeholder message. | | `reasoningTokens` | `string[]` | Optional reasoning chunks emitted before the text reply. | | `delayMs` | `number` | Milliseconds between successive token emissions. Defaults to `60`. | +| `script` | [`FakeAgentScript`](#script) | Raw AG-UI event branches that replace the canned reply. | + +The first three options are the shared `FakeAgentConfig` that every adapter's `provideFakeAgent()` accepts. `script` is the AG-UI-specific addition. ## FakeAgent class @@ -58,20 +61,60 @@ const agent = toAgent(new FakeAgent({ ### script -The constructor accepts a fourth option that `FakeAgentConfig` does not carry, so it is reachable only by constructing `FakeAgent` yourself: +`FakeAgentScript` is an exported type. Both the constructor and `provideFakeAgent()` accept it: ```ts -script?: readonly { +type FakeAgentScript = readonly { when: 'initial' | { toolMessageFor: string }; events: readonly BaseEvent[]; }[]; ``` -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. +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. -| Option | Type | Description | -|--------|------|-------------| -| `script` | `readonly { when: 'initial' \| { toolMessageFor: string }; events: readonly BaseEvent[] }[]` | Deterministic event branches. Constructor only — not part of `FakeAgentConfig`, so `provideFakeAgent()` cannot set it. | +Through DI, a script reaches the reducer exactly as wire events would, so `toolCalls()`, `state()`, `customEvents()`, and `interrupt()` all populate: + +```ts +import { TestBed } from '@angular/core/testing'; +import { EventType, type BaseEvent } from '@ag-ui/client'; +import { provideFakeAgent, injectAgent } from '@threadplane/ag-ui'; + +it('reduces a scripted tool call', async () => { + TestBed.configureTestingModule({ + providers: [ + provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + type: EventType.TOOL_CALL_START, + toolCallId: 'tool-1', + toolCallName: 'get_weather', + } as BaseEvent, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'tool-1', + delta: '{"city":"SF"}', + } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: 'tool-1' } as BaseEvent, + ], + }, + ], + }), + ], + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + await agent.submit({ message: 'weather?' }); + + expect(agent.toolCalls()[0]).toMatchObject({ + name: 'get_weather', + args: { city: 'SF' }, + }); +}); +``` ## TestBed example diff --git a/apps/website/content/docs/ag-ui/guides/fake-agent.mdx b/apps/website/content/docs/ag-ui/guides/fake-agent.mdx index c247734c2..4e580f8b5 100644 --- a/apps/website/content/docs/ag-ui/guides/fake-agent.mdx +++ b/apps/website/content/docs/ag-ui/guides/fake-agent.mdx @@ -1,5 +1,5 @@ --- -description: Run the chat UI with no backend using provideFakeAgent(), what the canned stream contains, and when to construct FakeAgent with a script instead. +description: Run the chat UI with no backend using provideFakeAgent(), what the canned stream contains, and how to script exact AG-UI events instead. --- # Fake Agent @@ -97,11 +97,40 @@ That gives you the same `Agent` contract as `provideFakeAgent()`. | `tokens` | `string[]` | A short canned greeting | Emitted as text deltas in order. | | `reasoningTokens` | `string[]` | `[]` | Emitted before text deltas. | | `delayMs` | `number` | `60` | Delay between events after the initial start delay. | -| `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`. | +| `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()`. | + +## Scripting exact events + +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: + +```ts +import { EventType, type BaseEvent } from '@ag-ui/client'; +import { provideFakeAgent } from '@threadplane/ag-ui'; + +providers: [ + provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + type: EventType.CUSTOM, + name: 'on_interrupt', + value: { kind: 'approval', amount: 42 }, + } as BaseEvent, + ], + }, + ], + }), +] +``` + +`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. ## What it does not do -`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. +`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`. It is deliberately small. Use it to keep UI work moving, not to validate backend behavior. diff --git a/apps/website/content/docs/ag-ui/guides/testing.mdx b/apps/website/content/docs/ag-ui/guides/testing.mdx index ecd0dc716..fffbdf951 100644 --- a/apps/website/content/docs/ag-ui/guides/testing.mdx +++ b/apps/website/content/docs/ag-ui/guides/testing.mdx @@ -1,5 +1,5 @@ --- -description: Test AG-UI components with provideFakeAgent(), the neutral mockAgent(), or a scripted AbstractAgent, and know which double covers which surface. +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. --- # Testing @@ -53,13 +53,14 @@ export class ChatHost { -The component is byte-identical to production — only the provider changes. `FakeAgentConfig` (`{ tokens?, reasoningTokens?, delayMs? }`) lives in `@threadplane/chat/testing`: +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`. | Option | Type | Notes | | --- | --- | --- | | `tokens` | `string[]` | Emitted as streamed text deltas, in order. | | `reasoningTokens` | `string[]` | Emitted before text deltas to exercise reasoning UI. | | `delayMs` | `number` | Delay between streamed events. | +| `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). | For the underlying `FakeAgent` class and its canned event sequence, see [Fake Agent](/docs/ag-ui/guides/fake-agent). @@ -109,7 +110,64 @@ expect(m.status()).toBe('running'); ## Testing tool calls, state, and custom events -`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. +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: + +```typescript +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { EventType, type BaseEvent } from '@ag-ui/client'; +import { provideFakeAgent, injectAgent } from '@threadplane/ag-ui'; + +describe('scripted provideFakeAgent()', () => { + it('reduces tool calls, state, and custom events', async () => { + TestBed.configureTestingModule({ + providers: [ + provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + 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, + ], + }, + ], + }), + ], + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + 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 } }); + }); +}); +``` + +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. + +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. ```typescript import { describe, it, expect } from 'vitest'; @@ -153,4 +211,4 @@ describe('scripted AG-UI events', () => { }); ``` -`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). +`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()`. diff --git a/libs/ag-ui/src/lib/testing/fake-agent.ts b/libs/ag-ui/src/lib/testing/fake-agent.ts index 4ccd4998b..105bdc1c1 100644 --- a/libs/ag-ui/src/lib/testing/fake-agent.ts +++ b/libs/ag-ui/src/lib/testing/fake-agent.ts @@ -7,14 +7,24 @@ import { } from '@ag-ui/client'; import { Observable } from 'rxjs'; -type FakeAgentScriptWhen = - | 'initial' - | { toolMessageFor: string }; - -interface FakeAgentScriptBranch { - when: FakeAgentScriptWhen; +/** + * Deterministic event branches for {@link FakeAgent}, reachable through the + * constructor and through `provideFakeAgent({ script })`. + * + * Each branch supplies a raw AG-UI event sequence — tool calls, state + * snapshots, 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. The first matching branch wins, and its `events` are wrapped in + * `RUN_STARTED` / `RUN_FINISHED`. When no branch matches, the canned token + * reply is streamed instead. + */ +export type FakeAgentScript = readonly { + when: 'initial' | { toolMessageFor: string }; events: readonly BaseEvent[]; -} +}[]; + +type FakeAgentScriptWhen = FakeAgentScript[number]['when']; /** * In-process AG-UI agent that emits a canned streaming response. @@ -38,14 +48,14 @@ export class FakeAgent extends AbstractAgent { private readonly delayMs: number; /** Optional deterministic event branches for tests that need exact streams. */ - private readonly script: readonly FakeAgentScriptBranch[]; + private readonly script: FakeAgentScript; constructor(opts: { tokens?: string[]; /** Optional reasoning chunks emitted before the text reply. */ reasoningTokens?: string[]; delayMs?: number; - script?: readonly FakeAgentScriptBranch[]; + script?: FakeAgentScript; } = {}) { super(); this.tokens = opts.tokens ?? [ diff --git a/libs/ag-ui/src/lib/testing/provide-fake-agent.spec.ts b/libs/ag-ui/src/lib/testing/provide-fake-agent.spec.ts index 9fab4b257..46a300c0a 100644 --- a/libs/ag-ui/src/lib/testing/provide-fake-agent.spec.ts +++ b/libs/ag-ui/src/lib/testing/provide-fake-agent.spec.ts @@ -1,9 +1,14 @@ // libs/ag-ui/src/lib/testing/provide-fake-agent.spec.ts -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { AGENT } from '../provide-agent'; +import { EventType, type BaseEvent } from '@ag-ui/client'; +import { AGENT, injectAgent } from '../provide-agent'; import { provideFakeAgent } from './provide-fake-agent'; +afterEach(() => { + TestBed.resetTestingModule(); +}); + describe('provideFakeAgent', () => { it('registers AGENT with a Fake-backed Agent', () => { TestBed.configureTestingModule({ providers: provideFakeAgent() }); @@ -20,3 +25,165 @@ describe('provideFakeAgent', () => { expect(agent).toBeDefined(); }); }); + +describe('provideFakeAgent — script', () => { + it('reduces a scripted tool call into toolCalls()', async () => { + TestBed.configureTestingModule({ + providers: provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + type: EventType.TOOL_CALL_START, + toolCallId: 'tool-1', + toolCallName: 'get_weather', + } as BaseEvent, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'tool-1', + delta: '{"city":"SF"}', + } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: 'tool-1' } as BaseEvent, + ], + }, + ], + }), + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + await agent.submit({ message: 'weather?' }); + + expect(agent.toolCalls()).toHaveLength(1); + expect(agent.toolCalls()[0]).toMatchObject({ + name: 'get_weather', + args: { city: 'SF' }, + }); + }); + + // Mirrors the fence in apps/website/content/docs/ag-ui/guides/testing.mdx. + it('reduces scripted state and custom events', async () => { + TestBed.configureTestingModule({ + providers: provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + type: EventType.STATE_SNAPSHOT, + snapshot: { topic: 'billing' }, + } as BaseEvent, + { + type: EventType.CUSTOM, + name: 'analysis_progress', + value: { pct: 100 }, + } as BaseEvent, + ], + }, + ], + }), + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + await agent.submit({ message: 'find docs' }); + + expect(agent.state()).toMatchObject({ topic: 'billing' }); + expect(agent.customEvents()).toContainEqual({ + name: 'analysis_progress', + data: { pct: 100 }, + }); + }); + + it('reduces a scripted CUSTOM on_interrupt into interrupt()', async () => { + TestBed.configureTestingModule({ + providers: provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + type: EventType.CUSTOM, + name: 'on_interrupt', + value: { kind: 'approval', amount: 42 }, + } as BaseEvent, + ], + }, + ], + }), + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + await agent.submit({ message: 'transfer' }); + + expect(agent.interrupt()?.value).toEqual({ kind: 'approval', amount: 42 }); + }); + + it('runs the { toolMessageFor } branch on the follow-up carrying the tool result', async () => { + TestBed.configureTestingModule({ + providers: provideFakeAgent({ + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { + type: EventType.TOOL_CALL_START, + toolCallId: 'tool-1', + toolCallName: 'get_weather', + } as BaseEvent, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'tool-1', + delta: '{"city":"SF"}', + } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: 'tool-1' } as BaseEvent, + ], + }, + { + when: { toolMessageFor: 'tool-1' }, + events: [ + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'assistant-2', + role: 'assistant', + } as BaseEvent, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'assistant-2', + delta: 'It is 70F in SF.', + } as BaseEvent, + { type: EventType.TEXT_MESSAGE_END, messageId: 'assistant-2' } as BaseEvent, + ], + }, + ], + }), + }); + + const agent = TestBed.runInInjectionContext(() => injectAgent()); + agent.clientTools.setCatalog([ + { + name: 'get_weather', + description: 'Returns current weather.', + parameters: { type: 'object', properties: { city: { type: 'string' } } }, + }, + ]); + + await agent.submit({ message: 'weather?' }); + expect(agent.clientTools.pending()).toHaveLength(1); + + // Handing back the tool result re-runs the agent with a tool message in + // history, which is what the { toolMessageFor } branch matches on. + // resolve() starts that continuation without awaiting it. + agent.clientTools.resolve('tool-1', { ok: true, value: { temp: 70 } }); + + await vi.waitFor(() => { + expect(agent.messages().at(-1)).toMatchObject({ + role: 'assistant', + content: 'It is 70F in SF.', + }); + }); + }); +}); diff --git a/libs/ag-ui/src/lib/testing/provide-fake-agent.ts b/libs/ag-ui/src/lib/testing/provide-fake-agent.ts index b5745fa57..492f8f701 100644 --- a/libs/ag-ui/src/lib/testing/provide-fake-agent.ts +++ b/libs/ag-ui/src/lib/testing/provide-fake-agent.ts @@ -3,7 +3,17 @@ import { type Provider } from '@angular/core'; import type { FakeAgentConfig } from '@threadplane/chat/testing'; import { AGENT } from '../provide-agent'; import { toAgent } from '../to-agent'; -import { FakeAgent } from './fake-agent'; +import { FakeAgent, type FakeAgentScript } from './fake-agent'; + +/** + * Config accepted by {@link provideFakeAgent}: the shared `FakeAgentConfig` + * (`tokens`, `reasoningTokens`, `delayMs`) plus the AG-UI-only `script`, which + * replaces the canned token reply with raw AG-UI events. + */ +export interface AgUiFakeAgentConfig extends FakeAgentConfig { + /** Deterministic event branches — see {@link FakeAgentScript}. */ + script?: FakeAgentScript; +} /** * Registers an in-process FakeAgent under AGENT. @@ -11,14 +21,35 @@ import { FakeAgent } from './fake-agent'; * Use for offline demos and development. Drop-in replacement for * provideAgent({ url }) when no real backend is available. * + * Pass `script` to stream exact AG-UI events instead of the canned token + * reply — the adapter reduces them into `toolCalls()`, `state()`, + * `customEvents()`, and `interrupt()` exactly as it would real wire events. + * * @example * ```ts * TestBed.configureTestingModule({ * providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })], * }); * ``` + * + * @example Scripted tool call + * ```ts + * TestBed.configureTestingModule({ + * providers: [provideFakeAgent({ + * delayMs: 0, + * script: [{ + * when: 'initial', + * events: [ + * { type: EventType.TOOL_CALL_START, toolCallId: 't1', toolCallName: 'get_weather' }, + * { type: EventType.TOOL_CALL_ARGS, toolCallId: 't1', delta: '{"city":"SF"}' }, + * { type: EventType.TOOL_CALL_END, toolCallId: 't1' }, + * ] as BaseEvent[], + * }], + * })], + * }); + * ``` */ -export function provideFakeAgent(config: FakeAgentConfig = {}): Provider[] { +export function provideFakeAgent(config: AgUiFakeAgentConfig = {}): Provider[] { return [ { provide: AGENT, diff --git a/libs/ag-ui/src/lib/testing/provide-fake-agent.type-spec.ts b/libs/ag-ui/src/lib/testing/provide-fake-agent.type-spec.ts new file mode 100644 index 000000000..3fad33297 --- /dev/null +++ b/libs/ag-ui/src/lib/testing/provide-fake-agent.type-spec.ts @@ -0,0 +1,30 @@ +import type { BaseEvent } from '@ag-ui/client'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { FakeAgentScript } from './fake-agent'; +import { provideFakeAgent, type AgUiFakeAgentConfig } from './provide-fake-agent'; + +// `script` is part of the accepted config, not constructor-only: an object +// literal carrying it must not trip excess-property checking. +provideFakeAgent({ + tokens: ['hi'], + delayMs: 0, + script: [ + { + when: 'initial', + events: [ + { type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as BaseEvent, + ], + }, + { + when: { toolMessageFor: 't1' }, + events: [{ type: 'TEXT_MESSAGE_START', messageId: 'm1' } as BaseEvent], + }, + ], +}); + +// The shared fields survive, and `script` reaches the constructor's own type. +type _script = Expect>; +type _tokens = Expect>; +type _when = Expect< + Equal +>; diff --git a/libs/ag-ui/src/public-api.ts b/libs/ag-ui/src/public-api.ts index 3baa322a3..f6a2c3206 100644 --- a/libs/ag-ui/src/public-api.ts +++ b/libs/ag-ui/src/public-api.ts @@ -6,7 +6,9 @@ export type { AgentConfig } from './lib/provide-agent'; export { ɵAG_UI_RUNTIME_OPERATION_REPORTER } from './lib/runtime-operation-reporter'; export type { RuntimeOperationFailureReporter as ɵAgUiRuntimeOperationFailureReporter } from './lib/runtime-operation-reporter'; export { FakeAgent } from './lib/testing/fake-agent'; +export type { FakeAgentScript } from './lib/testing/fake-agent'; export { provideFakeAgent } from './lib/testing/provide-fake-agent'; +export type { AgUiFakeAgentConfig } from './lib/testing/provide-fake-agent'; // Citation state bridge export { bridgeCitationsState } from './lib/bridge-citations-state';