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/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/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/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 }, ]; } 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';