Skip to content

Commit 14ee674

Browse files
bloveclaude
andcommitted
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>
1 parent 3f6d73f commit 14ee674

4 files changed

Lines changed: 133 additions & 4 deletions

File tree

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

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

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

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

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

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

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

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { Component, inject } from '@angular/core';
3+
import { TestBed } from '@angular/core/testing';
4+
import { createAgentRef } from '@threadplane/chat';
5+
import { provideAgent, injectAgent } from './provide-agent';
6+
7+
afterEach(() => {
8+
TestBed.resetTestingModule();
9+
vi.restoreAllMocks();
10+
});
11+
12+
describe('provideAgent — several refs at one injector level', () => {
13+
it('warns once, naming both refs, and still hands out distinct agents', () => {
14+
const REF_A = createAgentRef<Record<string, unknown>>('alpha-agent');
15+
const REF_B = createAgentRef<Record<string, unknown>>('beta-agent');
16+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
17+
TestBed.configureTestingModule({
18+
providers: [
19+
provideAgent(REF_A, { url: 'http://a.example/agent' }),
20+
provideAgent(REF_B, { url: 'http://b.example/agent' }),
21+
],
22+
});
23+
24+
const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A));
25+
const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B));
26+
27+
expect(agentA).not.toBe(agentB);
28+
expect(warn).toHaveBeenCalledTimes(1);
29+
const message = String(warn.mock.calls[0][0]);
30+
expect(message).toContain('alpha-agent');
31+
expect(message).toContain('beta-agent');
32+
expect(message).toContain('injectAgent()');
33+
// The bare token still resolves the LAST ref provided, as the warning says.
34+
expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agentB);
35+
expect(warn).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it('does not warn for a single ref at a level', () => {
39+
const REF = createAgentRef<Record<string, unknown>>('only-agent');
40+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
41+
TestBed.configureTestingModule({
42+
providers: [provideAgent(REF, { url: 'http://single.example/agent' })],
43+
});
44+
45+
TestBed.runInInjectionContext(() => injectAgent(REF));
46+
TestBed.runInInjectionContext(() => injectAgent());
47+
48+
expect(warn).not.toHaveBeenCalled();
49+
});
50+
51+
it('does not warn when refs sit at different injector levels', () => {
52+
const ROOT_REF = createAgentRef<Record<string, unknown>>('root-agent');
53+
const LEAF_REF = createAgentRef<Record<string, unknown>>('leaf-agent');
54+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
55+
56+
@Component({
57+
template: '',
58+
providers: [provideAgent(LEAF_REF, { url: 'http://leaf.example/agent' })],
59+
})
60+
class LeafComponent {
61+
readonly leaf = injectAgent(LEAF_REF);
62+
readonly root = inject(ROOT_REF.token);
63+
}
64+
65+
TestBed.configureTestingModule({
66+
imports: [LeafComponent],
67+
providers: [provideAgent(ROOT_REF, { url: 'http://root.example/agent' })],
68+
});
69+
const fixture = TestBed.createComponent(LeafComponent);
70+
fixture.detectChanges();
71+
72+
expect(fixture.componentInstance.leaf).not.toBe(fixture.componentInstance.root);
73+
expect(warn).not.toHaveBeenCalled();
74+
});
75+
});

libs/ag-ui/src/lib/provide-agent.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { InjectionToken, inject, type Provider } from '@angular/core';
1+
import { InjectionToken, inject, isDevMode, type Provider } from '@angular/core';
22
import { HttpAgent } from '@ag-ui/client';
33
import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat';
44
import { toAgent, ɵtoAgentWithProtectedErrors, type AgUiAgent } from './to-agent';
@@ -62,6 +62,39 @@ function isAgentRef<T>(x: unknown): x is AgentRef<T> {
6262
return typeof x === 'object' && x !== null && 'token' in x;
6363
}
6464

65+
/**
66+
* @internal — one entry per ref-form `provideAgent()` call, in registration
67+
* order. Multi providers do not merge across injectors, so the resolved array
68+
* names exactly the refs registered at the injector that resolves it.
69+
*/
70+
const AGENT_REF_DEBUG_NAMES = new InjectionToken<string[]>('AG_UI_AGENT_REF_DEBUG_NAMES');
71+
72+
/** @internal — one warning per injector, not one per agent built there. */
73+
const warnedRefNameSets = new WeakSet<object>();
74+
75+
/**
76+
* @internal — development-only notice that the shared `AGENT` alias is
77+
* ambiguous at this injector level. Must run inside an injection context.
78+
*/
79+
function warnOnAmbiguousSharedAlias(): void {
80+
if (!isDevMode()) return;
81+
const names = inject(AGENT_REF_DEBUG_NAMES, { optional: true });
82+
if (names === null || names.length < 2 || warnedRefNameSets.has(names)) return;
83+
warnedRefNameSets.add(names);
84+
console.warn(
85+
`[@threadplane/ag-ui] provideAgent() was called with more than one AgentRef at the same ` +
86+
`injector level (${names.join(', ')}). The ref-less injectAgent() reads a single shared ` +
87+
`token, so it resolves the last ref provided (${names[names.length - 1]}) and the others ` +
88+
`are reachable only by ref. Inject by ref — injectAgent(ref) — when an injector provides ` +
89+
`more than one agent.`,
90+
);
91+
}
92+
93+
/** @internal — the name shown in the ambiguity warning. */
94+
function refDebugName<T>(ref: AgentRef<T>): string {
95+
return String(ref.token).replace(/^InjectionToken\s+/, '');
96+
}
97+
6598
/**
6699
* Provides an Agent instance wired through HttpAgent and toAgent.
67100
* Constructs an HttpAgent from config and wraps it in the runtime-neutral
@@ -83,7 +116,9 @@ function isAgentRef<T>(x: unknown): x is AgentRef<T> {
83116
* distinct agents. The ref-less `injectAgent()` resolves a single shared token,
84117
* which can only point at one of them: when more than one ref is provided at
85118
* the same level the **last** call wins. Always inject by ref when an injector
86-
* provides more than one agent.
119+
* provides more than one agent. Development builds emit a one-time
120+
* `console.warn` naming the refs involved when an injector level registers more
121+
* than one, so the silent last-ref-wins aliasing is visible during development.
87122
*
88123
* @example Typed state via AgentRef
89124
* ```ts
@@ -117,7 +152,14 @@ export function provideAgent<T = Record<string, unknown>>(
117152
// instance, one config evaluation); with several refs AGENT can only mean one
118153
// thing, so the last call wins.
119154
return [
120-
{ provide: ref.token, useFactory: () => buildAgUiAgent(configOrFactory) },
155+
{ provide: AGENT_REF_DEBUG_NAMES, multi: true, useValue: refDebugName(ref) },
156+
{
157+
provide: ref.token,
158+
useFactory: () => {
159+
warnOnAmbiguousSharedAlias();
160+
return buildAgUiAgent(configOrFactory);
161+
},
162+
},
121163
{ provide: AGENT, useExisting: ref.token },
122164
];
123165
}

0 commit comments

Comments
 (0)