Skip to content

Commit 0332c3c

Browse files
committed
test(core,react-core): port isolation regression tests to registerProxiedAgent
Re-adds the isolation coverage that the per-thread cloning revert deleted, rewritten against the explicit-registration model: - 9 new core-level tests in core-register-proxied-agent.test.ts cover the cases from the deleted use-agent-thread-isolation.test.tsx — distinct instances when two proxies target the same remoteAgentId, message and state isolation between proxies, independent threadId per proxy, shared outbound URL (both route to remoteAgentId), getAgent identity, pending registration before runtime connect, registration with a remote id the runtime doesn't yet know, and re-register-after-unregister yielding a fresh proxy. - 1 new react-core test in CopilotChatActivityRendering.e2e.test.tsx replaces the deleted "passes the per-thread clone to activity message renderers" regression test. The clone-vs-registry trap is gone in the new model, so the test is reframed: the renderer must receive the agent registered under the local agentId, not any other agent in the registry (e.g. the runtime-side id a proxy might route to). Total: +10 tests. Core 415 → 424, react-core 1150 → 1151.
1 parent e576bc1 commit 0332c3c

2 files changed

Lines changed: 246 additions & 0 deletions

File tree

packages/core/src/__tests__/core-register-proxied-agent.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,188 @@ describe("CopilotKitCore.registerProxiedAgent", () => {
112112
expect(agent.headers?.Authorization).toBe("Bearer abc");
113113
});
114114
});
115+
116+
/**
117+
* Isolation behavior — ports the cases from the deleted
118+
* `use-agent-thread-isolation.test.tsx` to the new explicit-registration model.
119+
*
120+
* The old design implicitly cloned a registry agent per (agentId, threadId).
121+
* The new model: callers register a distinct proxy per logical "chat", each
122+
* with its own local agentId. Multiple proxies can point at the same
123+
* `remoteAgentId` to share a runtime agent without sharing in-memory state.
124+
*/
125+
describe("CopilotKitCore.registerProxiedAgent — isolation", () => {
126+
let core: CopilotKitCore;
127+
128+
beforeEach(() => {
129+
core = new CopilotKitCore({ runtimeUrl: "http://localhost:4000" });
130+
});
131+
132+
it("two proxies registered against the same remoteAgentId are distinct instances", () => {
133+
const a = core.registerProxiedAgent({
134+
agentId: "chat-1",
135+
remoteAgentId: "default",
136+
});
137+
const b = core.registerProxiedAgent({
138+
agentId: "chat-2",
139+
remoteAgentId: "default",
140+
});
141+
142+
expect(a.agent).not.toBe(b.agent);
143+
expect(core.getAgent("chat-1")).toBe(a.agent);
144+
expect(core.getAgent("chat-2")).toBe(b.agent);
145+
expect(a.agent.agentId).toBe("chat-1");
146+
expect(b.agent.agentId).toBe("chat-2");
147+
expect(a.agent.remoteAgentId).toBe("default");
148+
expect(b.agent.remoteAgentId).toBe("default");
149+
});
150+
151+
it("two proxies share the same outbound URL (both route to remoteAgentId)", () => {
152+
const a = core.registerProxiedAgent({
153+
agentId: "chat-1",
154+
remoteAgentId: "default",
155+
});
156+
const b = core.registerProxiedAgent({
157+
agentId: "chat-2",
158+
remoteAgentId: "default",
159+
});
160+
161+
const urlA = (a.agent as unknown as { url: string }).url;
162+
const urlB = (b.agent as unknown as { url: string }).url;
163+
expect(urlA).toBe(urlB);
164+
expect(urlA).toContain("/agent/default/run");
165+
expect(urlA).not.toContain("chat-1");
166+
expect(urlA).not.toContain("chat-2");
167+
});
168+
169+
it("isolates messages between two proxies pointing to the same remoteAgentId", () => {
170+
const a = core.registerProxiedAgent({
171+
agentId: "chat-1",
172+
remoteAgentId: "default",
173+
});
174+
const b = core.registerProxiedAgent({
175+
agentId: "chat-2",
176+
remoteAgentId: "default",
177+
});
178+
179+
a.agent.addMessage({
180+
id: "msg-a-1",
181+
role: "user",
182+
content: "hello from chat-1",
183+
});
184+
185+
expect(a.agent.messages).toHaveLength(1);
186+
expect(b.agent.messages).toHaveLength(0);
187+
188+
b.agent.addMessage({
189+
id: "msg-b-1",
190+
role: "user",
191+
content: "hello from chat-2",
192+
});
193+
194+
expect(a.agent.messages).toHaveLength(1);
195+
expect(b.agent.messages).toHaveLength(1);
196+
expect(a.agent.messages[0]?.id).toBe("msg-a-1");
197+
expect(b.agent.messages[0]?.id).toBe("msg-b-1");
198+
});
199+
200+
it("isolates state between two proxies pointing to the same remoteAgentId", () => {
201+
const a = core.registerProxiedAgent({
202+
agentId: "chat-1",
203+
remoteAgentId: "default",
204+
});
205+
const b = core.registerProxiedAgent({
206+
agentId: "chat-2",
207+
remoteAgentId: "default",
208+
});
209+
210+
a.agent.setState({ counter: 1 });
211+
b.agent.setState({ counter: 99 });
212+
213+
expect(a.agent.state).toEqual({ counter: 1 });
214+
expect(b.agent.state).toEqual({ counter: 99 });
215+
});
216+
217+
it("each proxy can hold its own threadId without affecting others", () => {
218+
const a = core.registerProxiedAgent({
219+
agentId: "chat-1",
220+
remoteAgentId: "default",
221+
});
222+
const b = core.registerProxiedAgent({
223+
agentId: "chat-2",
224+
remoteAgentId: "default",
225+
});
226+
227+
a.agent.threadId = "thread-a";
228+
b.agent.threadId = "thread-b";
229+
230+
expect(a.agent.threadId).toBe("thread-a");
231+
expect(b.agent.threadId).toBe("thread-b");
232+
});
233+
234+
it("getAgent returns the same proxy instance across calls (no per-call clone)", () => {
235+
const { agent } = core.registerProxiedAgent({
236+
agentId: "chat-1",
237+
remoteAgentId: "default",
238+
});
239+
240+
expect(core.getAgent("chat-1")).toBe(agent);
241+
expect(core.getAgent("chat-1")).toBe(agent);
242+
});
243+
244+
it("registering before runtime connects yields a proxy in pending runtimeMode", () => {
245+
// Disconnected core (no runtime URL set) — proxy still mints, but in a
246+
// mode that doesn't try to call out before /info has been resolved.
247+
const offlineCore = new CopilotKitCore({});
248+
const { agent } = offlineCore.registerProxiedAgent({
249+
agentId: "chat-1",
250+
remoteAgentId: "default",
251+
});
252+
expect(agent).toBeInstanceOf(ProxiedCopilotRuntimeAgent);
253+
// The proxy is registered and queryable even with no runtime URL.
254+
expect(offlineCore.getAgent("chat-1")).toBe(agent);
255+
});
256+
257+
it("a proxy registered with a remote id that doesn't (yet) exist on the runtime is still usable for in-memory ops", () => {
258+
// The runtime might not know `phantom` — the proxy is opaque about that.
259+
// Registering doesn't validate against /info; the user vouches for the
260+
// remote id. Local subscriber bookkeeping (messages, state, threadId)
261+
// works regardless.
262+
const { agent } = core.registerProxiedAgent({
263+
agentId: "chat-1",
264+
remoteAgentId: "phantom",
265+
});
266+
267+
agent.addMessage({ id: "m", role: "user", content: "test" });
268+
agent.setState({ ok: true });
269+
agent.threadId = "t";
270+
271+
expect(agent.messages).toHaveLength(1);
272+
expect(agent.state).toEqual({ ok: true });
273+
expect(agent.threadId).toBe("t");
274+
expect(agent.remoteAgentId).toBe("phantom");
275+
});
276+
277+
it("registering, unregistering, and re-registering the same agentId yields a fresh proxy", () => {
278+
const first = core.registerProxiedAgent({
279+
agentId: "chat-1",
280+
remoteAgentId: "default",
281+
});
282+
first.agent.addMessage({
283+
id: "m-1",
284+
role: "user",
285+
content: "before unregister",
286+
});
287+
288+
first.unregister();
289+
290+
const second = core.registerProxiedAgent({
291+
agentId: "chat-1",
292+
remoteAgentId: "default",
293+
});
294+
295+
expect(second.agent).not.toBe(first.agent);
296+
// The fresh proxy starts empty — no carry-over from the previous proxy.
297+
expect(second.agent.messages).toHaveLength(0);
298+
});
299+
});

packages/react-core/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { z } from "zod";
4+
import type { AbstractAgent } from "@ag-ui/client";
45
import { EventType } from "@ag-ui/client";
56
import {
67
MockReconnectableAgent,
@@ -304,6 +305,66 @@ describe("CopilotChat activity message rendering", () => {
304305
expect(capturedCopilotkit).toBeDefined();
305306
});
306307

308+
it("activity renderers receive the agent under the config agentId, not any other registered agent", async () => {
309+
// Regression: the renderer's `agent` prop must come from
310+
// `copilotkit.getAgent(config.agentId)` — the local registry id from
311+
// CopilotChatConfigurationProvider — not from any other agent in the
312+
// registry (e.g. the runtime-side id a proxy might route to).
313+
//
314+
// Pre-#3525 → #3630 cycle, this manifested as a clone-vs-registry split.
315+
// In the explicit-registration model the trap is different: a refactor
316+
// could accidentally key on a proxy's `remoteAgentId` instead of its
317+
// local `agentId`. This test pins the local-id contract by mounting two
318+
// distinct mock agents and asserting the renderer receives the one that
319+
// matches `config.agentId`.
320+
const localAgent = new MockStepwiseAgent();
321+
localAgent.agentId = "chat-1";
322+
const otherAgent = new MockStepwiseAgent();
323+
otherAgent.agentId = "default";
324+
325+
let capturedAgent: AbstractAgent | undefined;
326+
const activityRenderer: ReactActivityMessageRenderer<{ label: string }> = {
327+
activityType: "button-action",
328+
content: z.object({ label: z.string() }),
329+
render: ({ content, agent: renderedAgent }) => {
330+
capturedAgent = renderedAgent;
331+
return <button data-testid="action-button">{content.label}</button>;
332+
},
333+
};
334+
335+
renderWithCopilotKit({
336+
agents: { "chat-1": localAgent, default: otherAgent },
337+
agentId: "chat-1",
338+
threadId: "thread-for-action-test",
339+
renderActivityMessages: [activityRenderer],
340+
});
341+
342+
const input = await screen.findByRole("textbox");
343+
fireEvent.change(input, { target: { value: "show me buttons" } });
344+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
345+
346+
await waitFor(() => {
347+
expect(screen.getByText("show me buttons")).toBeDefined();
348+
});
349+
350+
localAgent.emit(runStartedEvent());
351+
localAgent.emit(
352+
activitySnapshotEvent({
353+
messageId: testId("activity-action"),
354+
activityType: "button-action",
355+
content: { label: "Click Me" },
356+
}),
357+
);
358+
localAgent.emit(runFinishedEvent());
359+
360+
await waitFor(() => {
361+
expect(screen.getByTestId("action-button")).toBeDefined();
362+
});
363+
364+
expect(capturedAgent).toBe(localAgent);
365+
expect(capturedAgent).not.toBe(otherAgent);
366+
});
367+
307368
it("restores a completed A2UI surface after reconnect from an event-native baseline", async () => {
308369
const agent = new MockReconnectableAgent();
309370
const threadId = testId("a2ui-thread");

0 commit comments

Comments
 (0)