|
1 | 1 | import { describe, it, expect } from 'vitest'; |
| 2 | +import { InjectionToken, inject } from '@angular/core'; |
2 | 3 | import { TestBed } from '@angular/core/testing'; |
| 4 | +import { createAgentRef } from '@threadplane/chat'; |
3 | 5 | import { provideAgent, AGENT_CONFIG, AGENT } from './agent.provider'; |
| 6 | +import { injectAgent } from './inject-agent'; |
4 | 7 | import { MockAgentTransport } from './transport/mock-stream.transport'; |
5 | 8 |
|
6 | 9 | describe('provideAgent', () => { |
@@ -85,4 +88,112 @@ describe('provideAgent', () => { |
85 | 88 | // AGENT reads the already-resolved AGENT_CONFIG, so the factory ran once. |
86 | 89 | expect(calls).toBe(1); |
87 | 90 | }); |
| 91 | + |
| 92 | + describe('AgentRef isolation', () => { |
| 93 | + interface StateA { which: string } |
| 94 | + interface StateB { which: string } |
| 95 | + |
| 96 | + it('gives each ref its own agent and its own config in ONE providers array', async () => { |
| 97 | + const REF_A = createAgentRef<StateA>('ref-a'); |
| 98 | + const REF_B = createAgentRef<StateB>('ref-b'); |
| 99 | + const transportA = new MockAgentTransport(); |
| 100 | + const transportB = new MockAgentTransport(); |
| 101 | + TestBed.configureTestingModule({ |
| 102 | + providers: [ |
| 103 | + provideAgent(REF_A, { |
| 104 | + apiUrl: '', |
| 105 | + assistantId: 'graph-a', |
| 106 | + transport: transportA, |
| 107 | + initialValues: { which: 'a' }, |
| 108 | + }), |
| 109 | + provideAgent(REF_B, { |
| 110 | + apiUrl: '', |
| 111 | + assistantId: 'graph-b', |
| 112 | + transport: transportB, |
| 113 | + initialValues: { which: 'b' }, |
| 114 | + }), |
| 115 | + ], |
| 116 | + }); |
| 117 | + |
| 118 | + const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A)); |
| 119 | + const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B)); |
| 120 | + |
| 121 | + // Distinct instances... |
| 122 | + expect(agentA).not.toBe(agentB); |
| 123 | + // ...each built from ITS OWN config, not the last one registered. |
| 124 | + expect(agentA.value()).toEqual({ which: 'a' }); |
| 125 | + expect(agentB.value()).toEqual({ which: 'b' }); |
| 126 | + |
| 127 | + // And each is wired to its own transport: a submit on A must not reach B. |
| 128 | + void agentA.submit({ message: 'to-a' }); |
| 129 | + await Promise.resolve(); |
| 130 | + expect(transportA.streams.length).toBe(1); |
| 131 | + expect(transportB.streams.length).toBe(0); |
| 132 | + agentA.stop(); |
| 133 | + }); |
| 134 | + |
| 135 | + it('keeps single-ref behaviour identical: injectAgent() resolves the same instance', () => { |
| 136 | + const REF = createAgentRef<StateA>('single'); |
| 137 | + const transport = new MockAgentTransport(); |
| 138 | + TestBed.configureTestingModule({ |
| 139 | + providers: [ |
| 140 | + provideAgent(REF, { |
| 141 | + apiUrl: '', |
| 142 | + assistantId: 'single-graph', |
| 143 | + transport, |
| 144 | + initialValues: { which: 'single' }, |
| 145 | + }), |
| 146 | + ], |
| 147 | + }); |
| 148 | + |
| 149 | + const byRef = TestBed.runInInjectionContext(() => injectAgent(REF)); |
| 150 | + const bare = TestBed.runInInjectionContext(() => injectAgent()); |
| 151 | + expect(bare).toBe(byRef); |
| 152 | + expect(TestBed.inject(AGENT)).toBe(byRef); |
| 153 | + // The internal AGENT_CONFIG token still resolves for a ref-provided agent. |
| 154 | + expect(TestBed.inject(AGENT_CONFIG).assistantId).toBe('single-graph'); |
| 155 | + expect(TestBed.inject(AGENT_CONFIG).transport).toBe(transport); |
| 156 | + }); |
| 157 | + |
| 158 | + it('resolves a ref config factory lazily inside an injection context, exactly once', () => { |
| 159 | + const REF = createAgentRef<StateA>('lazy'); |
| 160 | + const ASSISTANT_ID = new InjectionToken<string>('ASSISTANT_ID'); |
| 161 | + let calls = 0; |
| 162 | + TestBed.configureTestingModule({ |
| 163 | + providers: [ |
| 164 | + { provide: ASSISTANT_ID, useValue: 'from-di' }, |
| 165 | + provideAgent(REF, () => { |
| 166 | + calls += 1; |
| 167 | + // Only legal if the factory runs in an injection context. |
| 168 | + return { |
| 169 | + apiUrl: '', |
| 170 | + assistantId: inject(ASSISTANT_ID), |
| 171 | + transport: new MockAgentTransport(), |
| 172 | + initialValues: { which: 'lazy' }, |
| 173 | + }; |
| 174 | + }), |
| 175 | + ], |
| 176 | + }); |
| 177 | + // Not run at decoration time. |
| 178 | + expect(calls).toBe(0); |
| 179 | + |
| 180 | + const agent = TestBed.runInInjectionContext(() => injectAgent(REF)); |
| 181 | + expect(agent.value()).toEqual({ which: 'lazy' }); |
| 182 | + expect(TestBed.inject(AGENT_CONFIG).assistantId).toBe('from-di'); |
| 183 | + // Ref agent and AGENT_CONFIG share one resolution of the factory. |
| 184 | + expect(calls).toBe(1); |
| 185 | + expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agent); |
| 186 | + expect(calls).toBe(1); |
| 187 | + }); |
| 188 | + |
| 189 | + it('throws the same assistantId error through a ref token', () => { |
| 190 | + const REF = createAgentRef<StateA>('no-assistant'); |
| 191 | + TestBed.configureTestingModule({ |
| 192 | + providers: [provideAgent(REF, { apiUrl: 'http://localhost' })], |
| 193 | + }); |
| 194 | + expect(() => |
| 195 | + TestBed.runInInjectionContext(() => injectAgent(REF)), |
| 196 | + ).toThrow(/`assistantId` is required to construct the AGENT singleton/); |
| 197 | + }); |
| 198 | + }); |
88 | 199 | }); |
0 commit comments