@copilotkit/react-core 1.55.0-next.7 → 1.55.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-BDNjFNmk.cjs} +156 -92
  3. package/dist/copilotkit-BDNjFNmk.cjs.map +1 -0
  4. package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BqcyhQjT.d.mts} +3 -1
  5. package/dist/{copilotkit-Dy5w3qEV.d.mts.map → copilotkit-BqcyhQjT.d.mts.map} +1 -1
  6. package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-DeOzjPsb.mjs} +157 -93
  7. package/dist/copilotkit-DeOzjPsb.mjs.map +1 -0
  8. package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-l-IBF4Xp.d.cts} +3 -1
  9. package/dist/{copilotkit-DBzgOMby.d.cts.map → copilotkit-l-IBF4Xp.d.cts.map} +1 -1
  10. package/dist/index.cjs +9 -4
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.mjs +9 -4
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/index.umd.js +160 -94
  17. package/dist/index.umd.js.map +1 -1
  18. package/dist/v2/index.cjs +1 -1
  19. package/dist/v2/index.d.cts +1 -1
  20. package/dist/v2/index.d.mts +1 -1
  21. package/dist/v2/index.mjs +1 -1
  22. package/dist/v2/index.umd.js +160 -94
  23. package/dist/v2/index.umd.js.map +1 -1
  24. package/package.json +6 -6
  25. package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
  26. package/src/components/copilot-provider/copilotkit.tsx +3 -3
  27. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
  28. package/src/hooks/use-copilot-chat_internal.ts +15 -4
  29. package/src/v2/__tests__/utils/test-helpers.tsx +40 -7
  30. package/src/v2/components/chat/CopilotChat.tsx +4 -2
  31. package/src/v2/components/chat/CopilotChatMessageView.tsx +7 -2
  32. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
  33. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
  34. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +17 -1
  35. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
  36. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
  37. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
  38. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
  39. package/src/v2/hooks/use-agent.tsx +126 -6
  40. package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
  41. package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
  42. package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
@@ -0,0 +1,327 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import { renderHook } from "@testing-library/react";
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+ import {
6
+ AbstractAgent,
7
+ type BaseEvent,
8
+ type RunAgentInput,
9
+ } from "@ag-ui/client";
10
+ import { useCopilotKit } from "../../providers/CopilotKitProvider";
11
+ import { useAgent } from "../use-agent";
12
+ import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
13
+ import { Observable } from "rxjs";
14
+
15
+ vi.mock("../../providers/CopilotKitProvider", () => ({
16
+ useCopilotKit: vi.fn(),
17
+ }));
18
+
19
+ const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
20
+
21
+ /**
22
+ * A minimal mock agent whose clone() returns a NEW instance and copies
23
+ * messages from the source. This is essential for testing per-thread
24
+ * isolation — each clone must be a distinct object that starts with the
25
+ * source's state so that cloneForThread's setMessages([]) / setState({})
26
+ * calls are meaningful (not vacuously true on an already-empty clone).
27
+ */
28
+ class CloneableAgent extends AbstractAgent {
29
+ clone(): CloneableAgent {
30
+ const cloned = new CloneableAgent();
31
+ cloned.agentId = this.agentId;
32
+ // Copy messages so cloneForThread's setMessages([]) actually clears state
33
+ cloned.setMessages([...this.messages]);
34
+ return cloned;
35
+ }
36
+
37
+ run(_input: RunAgentInput): Observable<BaseEvent> {
38
+ return new Observable();
39
+ }
40
+ }
41
+
42
+ describe("useAgent thread isolation", () => {
43
+ let mockCopilotkit: {
44
+ getAgent: ReturnType<typeof vi.fn>;
45
+ runtimeUrl: string | undefined;
46
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
47
+ runtimeTransport: string;
48
+ headers: Record<string, string>;
49
+ agents: Record<string, AbstractAgent>;
50
+ };
51
+
52
+ let registeredAgent: CloneableAgent;
53
+
54
+ beforeEach(() => {
55
+ registeredAgent = new CloneableAgent();
56
+ registeredAgent.agentId = "my-agent";
57
+
58
+ mockCopilotkit = {
59
+ getAgent: vi.fn((id: string) =>
60
+ id === "my-agent" ? registeredAgent : undefined,
61
+ ),
62
+ runtimeUrl: "http://localhost:3000/api/copilotkit",
63
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
64
+ runtimeTransport: "rest",
65
+ headers: {},
66
+ agents: { "my-agent": registeredAgent },
67
+ };
68
+
69
+ mockUseCopilotKit.mockReturnValue({
70
+ copilotkit: mockCopilotkit,
71
+ executingToolCallIds: new Set(),
72
+ });
73
+ });
74
+
75
+ afterEach(() => {
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ it("returns different agent instances for different threadIds with the same agentId", () => {
80
+ const agents: Record<string, AbstractAgent> = {};
81
+
82
+ function TrackerA() {
83
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
84
+ agents["a"] = agent;
85
+ return null;
86
+ }
87
+
88
+ function TrackerB() {
89
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
90
+ agents["b"] = agent;
91
+ return null;
92
+ }
93
+
94
+ render(
95
+ <>
96
+ <TrackerA />
97
+ <TrackerB />
98
+ </>,
99
+ );
100
+
101
+ expect(agents["a"]).toBeDefined();
102
+ expect(agents["b"]).toBeDefined();
103
+ expect(agents["a"]).not.toBe(agents["b"]);
104
+ });
105
+
106
+ it("returns the same cached instance for the same (agentId, threadId) across re-renders", () => {
107
+ const instances: AbstractAgent[] = [];
108
+
109
+ function Tracker() {
110
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-x" });
111
+ instances.push(agent);
112
+ return null;
113
+ }
114
+
115
+ const { rerender } = render(<Tracker />);
116
+ rerender(<Tracker />);
117
+
118
+ expect(instances.length).toBe(2);
119
+ expect(instances[0]).toBe(instances[1]);
120
+ });
121
+
122
+ it("returns the shared registry agent when no threadId is provided (backward compat)", () => {
123
+ let captured: AbstractAgent | undefined;
124
+
125
+ function Tracker() {
126
+ const { agent } = useAgent({ agentId: "my-agent" });
127
+ captured = agent;
128
+ return null;
129
+ }
130
+
131
+ render(<Tracker />);
132
+ expect(captured).toBe(registeredAgent);
133
+ });
134
+
135
+ it("isolates messages between thread-specific agents", () => {
136
+ // Pre-populate the source agent so CloneableAgent.clone() copies the
137
+ // message into each clone — this makes cloneForThread's setMessages([])
138
+ // meaningful rather than vacuously true on an already-empty clone.
139
+ registeredAgent.addMessage({
140
+ id: "source-msg",
141
+ role: "user",
142
+ content: "pre-existing on source",
143
+ });
144
+
145
+ const agents: Record<string, AbstractAgent> = {};
146
+
147
+ function TrackerA() {
148
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
149
+ agents["a"] = agent;
150
+ return null;
151
+ }
152
+
153
+ function TrackerB() {
154
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
155
+ agents["b"] = agent;
156
+ return null;
157
+ }
158
+
159
+ render(
160
+ <>
161
+ <TrackerA />
162
+ <TrackerB />
163
+ </>,
164
+ );
165
+
166
+ // Both clones should start empty even though the source had a message —
167
+ // cloneForThread must have called setMessages([]) on each clone.
168
+ expect(agents["a"]!.messages).toHaveLength(0);
169
+ expect(agents["b"]!.messages).toHaveLength(0);
170
+
171
+ // Adding a message to thread A must not affect thread B
172
+ agents["a"]!.addMessage({
173
+ id: "msg-1",
174
+ role: "user",
175
+ content: "hello from thread A",
176
+ });
177
+
178
+ expect(agents["a"]!.messages).toHaveLength(1);
179
+ expect(agents["b"]!.messages).toHaveLength(0);
180
+ });
181
+
182
+ it("sets threadId on cloned agents", () => {
183
+ const agents: Record<string, AbstractAgent> = {};
184
+
185
+ function TrackerA() {
186
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
187
+ agents["a"] = agent;
188
+ return null;
189
+ }
190
+
191
+ function TrackerB() {
192
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
193
+ agents["b"] = agent;
194
+ return null;
195
+ }
196
+
197
+ render(
198
+ <>
199
+ <TrackerA />
200
+ <TrackerB />
201
+ </>,
202
+ );
203
+
204
+ expect(agents["a"]!.threadId).toBe("thread-a");
205
+ expect(agents["b"]!.threadId).toBe("thread-b");
206
+ });
207
+
208
+ it("invalidates stale clone when the registry agent is replaced", () => {
209
+ // Simulates reconnect / hot-reload: copilotkit.agents holds a new object.
210
+ const { result, rerender } = renderHook(
211
+ ({ tid }: { tid: string }) =>
212
+ useAgent({ agentId: "my-agent", threadId: tid }),
213
+ { initialProps: { tid: "thread-a" } },
214
+ );
215
+
216
+ const firstClone = result.current.agent;
217
+ expect(firstClone).not.toBe(registeredAgent); // it's a clone
218
+
219
+ // Replace the registry agent
220
+ const replacementAgent = new CloneableAgent();
221
+ replacementAgent.agentId = "my-agent";
222
+
223
+ mockCopilotkit.agents = { "my-agent": replacementAgent };
224
+ mockCopilotkit.getAgent.mockImplementation((id: string) =>
225
+ id === "my-agent" ? replacementAgent : undefined,
226
+ );
227
+ mockUseCopilotKit.mockReturnValue({
228
+ copilotkit: { ...mockCopilotkit },
229
+ executingToolCallIds: new Set(),
230
+ });
231
+
232
+ rerender({ tid: "thread-a" });
233
+
234
+ const secondClone = result.current.agent;
235
+ expect(secondClone).not.toBe(firstClone); // stale clone was invalidated
236
+ expect(secondClone).not.toBe(replacementAgent); // still a clone, not the source
237
+ });
238
+
239
+ it("switching threadId returns a fresh clone; switching back returns the cached one", () => {
240
+ const { result, rerender } = renderHook(
241
+ ({ tid }: { tid: string }) =>
242
+ useAgent({ agentId: "my-agent", threadId: tid }),
243
+ { initialProps: { tid: "thread-a" } },
244
+ );
245
+
246
+ const cloneA = result.current.agent;
247
+
248
+ rerender({ tid: "thread-b" });
249
+ const cloneB = result.current.agent;
250
+ expect(cloneB).not.toBe(cloneA);
251
+
252
+ // Switching back to thread-a should return the originally cached clone
253
+ rerender({ tid: "thread-a" });
254
+ expect(result.current.agent).toBe(cloneA);
255
+ });
256
+
257
+ it("uses a fresh clone with correct threadId when provisional transitions to real agent", () => {
258
+ // Start in Disconnected state — a provisional is created
259
+ mockCopilotkit.runtimeConnectionStatus =
260
+ CopilotKitCoreRuntimeConnectionStatus.Disconnected;
261
+ mockCopilotkit.getAgent.mockReturnValue(undefined);
262
+ mockCopilotkit.agents = {};
263
+ mockUseCopilotKit.mockReturnValue({
264
+ copilotkit: { ...mockCopilotkit },
265
+ executingToolCallIds: new Set(),
266
+ });
267
+
268
+ const { result, rerender } = renderHook(() =>
269
+ useAgent({ agentId: "my-agent", threadId: "thread-a" }),
270
+ );
271
+
272
+ const provisional = result.current.agent;
273
+ expect(provisional.threadId).toBe("thread-a");
274
+
275
+ // Real agent appears (runtime connected and agent registered)
276
+ mockCopilotkit.runtimeConnectionStatus =
277
+ CopilotKitCoreRuntimeConnectionStatus.Connected;
278
+ mockCopilotkit.getAgent.mockImplementation((id: string) =>
279
+ id === "my-agent" ? registeredAgent : undefined,
280
+ );
281
+ mockCopilotkit.agents = { "my-agent": registeredAgent };
282
+ mockUseCopilotKit.mockReturnValue({
283
+ copilotkit: { ...mockCopilotkit },
284
+ executingToolCallIds: new Set(),
285
+ });
286
+
287
+ rerender();
288
+
289
+ const realClone = result.current.agent;
290
+ expect(realClone).not.toBe(provisional); // provisional replaced by real clone
291
+ expect(realClone).not.toBe(registeredAgent); // it's a clone, not the source
292
+ expect(realClone.threadId).toBe("thread-a");
293
+ });
294
+
295
+ it("uses composite key for provisional agents when threadId is provided", () => {
296
+ // Put runtime in Disconnected state so provisionals are created
297
+ mockCopilotkit.runtimeConnectionStatus =
298
+ CopilotKitCoreRuntimeConnectionStatus.Disconnected;
299
+ mockCopilotkit.getAgent.mockReturnValue(undefined);
300
+ mockCopilotkit.agents = {};
301
+
302
+ const agents: Record<string, AbstractAgent> = {};
303
+
304
+ function TrackerA() {
305
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
306
+ agents["a"] = agent;
307
+ return null;
308
+ }
309
+
310
+ function TrackerB() {
311
+ const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
312
+ agents["b"] = agent;
313
+ return null;
314
+ }
315
+
316
+ render(
317
+ <>
318
+ <TrackerA />
319
+ <TrackerB />
320
+ </>,
321
+ );
322
+
323
+ expect(agents["a"]).not.toBe(agents["b"]);
324
+ expect(agents["a"]!.threadId).toBe("thread-a");
325
+ expect(agents["b"]!.threadId).toBe("thread-b");
326
+ });
327
+ });
@@ -19,10 +19,21 @@ import { CopilotChat } from "../../components/chat/CopilotChat";
19
19
  * Mock agent that captures RunAgentInput to verify state is passed correctly
20
20
  */
21
21
  class StateCapturingMockAgent extends MockStepwiseAgent {
22
- public lastRunInput?: RunAgentInput;
22
+ // Shared via a container so the clone and original both see the same value
23
+ private _capture: { lastRunInput?: RunAgentInput } = {};
24
+
25
+ get lastRunInput(): RunAgentInput | undefined {
26
+ return this._capture.lastRunInput;
27
+ }
28
+
29
+ clone(): this {
30
+ const cloned = super.clone();
31
+ (cloned as unknown as StateCapturingMockAgent)._capture = this._capture;
32
+ return cloned;
33
+ }
23
34
 
24
35
  run(input: RunAgentInput): Observable<BaseEvent> {
25
- this.lastRunInput = input;
36
+ this._capture.lastRunInput = input;
26
37
  return super.run(input);
27
38
  }
28
39
  }
@@ -502,13 +502,24 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
502
502
  describe("Agent input plumbing", () => {
503
503
  it("forwards registered frontend tools to runAgent input", async () => {
504
504
  class InstrumentedMockAgent extends MockStepwiseAgent {
505
- public lastRunParameters?: RunAgentParameters;
505
+ // Shared so the clone and original both see the captured parameters
506
+ private _capture: { lastRunParameters?: RunAgentParameters } = {};
507
+
508
+ get lastRunParameters(): RunAgentParameters | undefined {
509
+ return this._capture.lastRunParameters;
510
+ }
511
+
512
+ clone(): this {
513
+ const cloned = super.clone();
514
+ (cloned as unknown as InstrumentedMockAgent)._capture = this._capture;
515
+ return cloned;
516
+ }
506
517
 
507
518
  async runAgent(
508
519
  parameters?: RunAgentParameters,
509
520
  subscriber?: AgentSubscriber,
510
521
  ) {
511
- this.lastRunParameters = parameters;
522
+ this._capture.lastRunParameters = parameters;
512
523
  return super.runAgent(parameters, subscriber);
513
524
  }
514
525
  }
@@ -568,8 +579,16 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
568
579
  class OneShotToolCallAgent extends AbstractAgent {
569
580
  private runCount = 0;
570
581
  clone(): OneShotToolCallAgent {
571
- // Keep state across runs so the second run emits different args
572
- return this;
582
+ const cloned = new OneShotToolCallAgent();
583
+ cloned.agentId = this.agentId;
584
+ // Share runCount via reference so the second run emits different args
585
+ Object.defineProperty(cloned, "runCount", {
586
+ get: () => this.runCount,
587
+ set: (v: number) => {
588
+ this.runCount = v;
589
+ },
590
+ });
591
+ return cloned;
573
592
  }
574
593
  async detachActiveRun(): Promise<void> {}
575
594
  run(_input: RunAgentInput): Observable<BaseEvent> {
@@ -1,7 +1,8 @@
1
1
  import { useCopilotKit } from "../providers/CopilotKitProvider";
2
+ import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
2
3
  import { useMemo, useEffect, useReducer, useRef } from "react";
3
4
  import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
4
- import { AbstractAgent } from "@ag-ui/client";
5
+ import { AbstractAgent, HttpAgent } from "@ag-ui/client";
5
6
  import {
6
7
  ProxiedCopilotRuntimeAgent,
7
8
  CopilotKitCoreRuntimeConnectionStatus,
@@ -21,13 +22,88 @@ const ALL_UPDATES: UseAgentUpdate[] = [
21
22
 
22
23
  export interface UseAgentProps {
23
24
  agentId?: string;
25
+ threadId?: string;
24
26
  updates?: UseAgentUpdate[];
25
27
  }
26
28
 
27
- export function useAgent({ agentId, updates }: UseAgentProps = {}) {
29
+ /**
30
+ * Clone a registry agent for per-thread isolation.
31
+ * Copies agent configuration (transport, headers, etc.) but resets conversation
32
+ * state (messages, threadId, state) so each thread starts fresh.
33
+ */
34
+ function cloneForThread(
35
+ source: AbstractAgent,
36
+ threadId: string,
37
+ headers: Record<string, string>,
38
+ ): AbstractAgent {
39
+ const clone = source.clone();
40
+ if (clone === source) {
41
+ throw new Error(
42
+ `useAgent: ${source.constructor.name}.clone() returned the same instance. ` +
43
+ `clone() must return a new, independent object.`,
44
+ );
45
+ }
46
+ clone.threadId = threadId;
47
+ clone.setMessages([]);
48
+ clone.setState({});
49
+ if (clone instanceof HttpAgent) {
50
+ clone.headers = { ...headers };
51
+ }
52
+ return clone;
53
+ }
54
+
55
+ /**
56
+ * Module-level WeakMap: registryAgent → (threadId → clone).
57
+ * Shared across all useAgent() calls so that every component using the same
58
+ * (agentId, threadId) pair receives the same agent instance. Using WeakMap
59
+ * ensures the clone map is garbage-collected when the registry agent is
60
+ * replaced (e.g. after reconnect or hot-reload).
61
+ */
62
+ export const globalThreadCloneMap = new WeakMap<
63
+ AbstractAgent,
64
+ Map<string, AbstractAgent>
65
+ >();
66
+
67
+ /**
68
+ * Look up an existing per-thread clone without creating one.
69
+ * Returns undefined when no clone has been created yet for this pair.
70
+ */
71
+ export function getThreadClone(
72
+ registryAgent: AbstractAgent | undefined | null,
73
+ threadId: string | undefined | null,
74
+ ): AbstractAgent | undefined {
75
+ if (!registryAgent || !threadId) return undefined;
76
+ return globalThreadCloneMap.get(registryAgent)?.get(threadId);
77
+ }
78
+
79
+ function getOrCreateThreadClone(
80
+ existing: AbstractAgent,
81
+ threadId: string,
82
+ headers: Record<string, string>,
83
+ ): AbstractAgent {
84
+ let byThread = globalThreadCloneMap.get(existing);
85
+ if (!byThread) {
86
+ byThread = new Map();
87
+ globalThreadCloneMap.set(existing, byThread);
88
+ }
89
+ const cached = byThread.get(threadId);
90
+ if (cached) return cached;
91
+
92
+ const clone = cloneForThread(existing, threadId, headers);
93
+ byThread.set(threadId, clone);
94
+ return clone;
95
+ }
96
+
97
+ export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
28
98
  agentId ??= DEFAULT_AGENT_ID;
29
99
 
30
100
  const { copilotkit } = useCopilotKit();
101
+ // Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
102
+ // that useAgent() called without explicit threadId (e.g. inside a custom
103
+ // message renderer) automatically uses the same per-thread clone as the
104
+ // CopilotChat component it lives within.
105
+ const chatConfig = useCopilotChatConfiguration();
106
+ threadId ??= chatConfig?.threadId;
31
107
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
32
108
 
33
109
  const updateFlags = useMemo(
@@ -43,11 +119,29 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
43
119
  );
44
120
 
45
121
  const agent: AbstractAgent = useMemo(() => {
122
+ // Use a composite key when threadId is provided so that different threads
123
+ // for the same agent get independent instances.
124
+ const cacheKey = threadId ? `${agentId}:${threadId}` : agentId;
125
+
46
126
  const existing = copilotkit.getAgent(agentId);
47
127
  if (existing) {
48
- // Real agent found — clear any cached provisional for this ID
128
+ // Real agent found — clear any cached provisionals for this key and the
129
+ // bare agentId key (handles the case where a provisional was created
130
+ // before threadId was available, then the component re-renders with one).
131
+ provisionalAgentCache.current.delete(cacheKey);
49
132
  provisionalAgentCache.current.delete(agentId);
50
- return existing;
133
+
134
+ if (!threadId) {
135
+ // No threadId — return the shared registry agent (original behavior)
136
+ return existing;
137
+ }
138
+
139
+ // threadId provided — return the shared per-thread clone.
140
+ // The global WeakMap ensures all components using the same
141
+ // (registryAgent, threadId) pair receive the same instance, so state
142
+ // mutations (addMessage, setState) are visible everywhere. The WeakMap
143
+ // entry is GC-collected automatically when the registry agent is replaced.
144
+ return getOrCreateThreadClone(existing, threadId, copilotkit.headers);
51
145
  }
52
146
 
53
147
  const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
@@ -60,7 +154,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
60
154
  status === CopilotKitCoreRuntimeConnectionStatus.Connecting)
61
155
  ) {
62
156
  // Return cached provisional if available (keeps reference stable)
63
- const cached = provisionalAgentCache.current.get(agentId);
157
+ const cached = provisionalAgentCache.current.get(cacheKey);
64
158
  if (cached) {
65
159
  // Update headers on the cached agent in case they changed
66
160
  cached.headers = { ...copilotkit.headers };
@@ -75,7 +169,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
75
169
  });
76
170
  // Apply current headers so runs/connects inherit them
77
171
  provisional.headers = { ...copilotkit.headers };
78
- provisionalAgentCache.current.set(agentId, provisional);
172
+ if (threadId) {
173
+ provisional.threadId = threadId;
174
+ }
175
+ provisionalAgentCache.current.set(cacheKey, provisional);
79
176
  return provisional;
80
177
  }
81
178
 
@@ -88,6 +185,14 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
88
185
  isRuntimeConfigured &&
89
186
  status === CopilotKitCoreRuntimeConnectionStatus.Error
90
187
  ) {
188
+ // Cache the provisional so that dep changes while in Error state (e.g.
189
+ // headers update) return the same agent reference, matching the
190
+ // Disconnected/Connecting path and preventing spurious re-subscriptions.
191
+ const cached = provisionalAgentCache.current.get(cacheKey);
192
+ if (cached) {
193
+ cached.headers = { ...copilotkit.headers };
194
+ return cached;
195
+ }
91
196
  const provisional = new ProxiedCopilotRuntimeAgent({
92
197
  runtimeUrl: copilotkit.runtimeUrl,
93
198
  agentId,
@@ -95,6 +200,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
95
200
  runtimeMode: "pending",
96
201
  });
97
202
  provisional.headers = { ...copilotkit.headers };
203
+ if (threadId) {
204
+ provisional.threadId = threadId;
205
+ }
206
+ provisionalAgentCache.current.set(cacheKey, provisional);
98
207
  return provisional;
99
208
  }
100
209
 
@@ -113,6 +222,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
113
222
  // eslint-disable-next-line react-hooks/exhaustive-deps
114
223
  }, [
115
224
  agentId,
225
+ threadId,
116
226
  copilotkit.agents,
117
227
  copilotkit.runtimeConnectionStatus,
118
228
  copilotkit.runtimeUrl,
@@ -149,6 +259,16 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
149
259
  // eslint-disable-next-line react-hooks/exhaustive-deps
150
260
  }, [agent, forceUpdate, JSON.stringify(updateFlags)]);
151
261
 
262
+ // Keep HttpAgent headers fresh without mutating inside useMemo, which is
263
+ // unsafe in concurrent mode (React may invoke useMemo multiple times and
264
+ // discard intermediate results, but mutations always land).
265
+ useEffect(() => {
266
+ if (agent instanceof HttpAgent) {
267
+ agent.headers = { ...copilotkit.headers };
268
+ }
269
+ // eslint-disable-next-line react-hooks/exhaustive-deps
270
+ }, [agent, JSON.stringify(copilotkit.headers)]);
271
+
152
272
  return {
153
273
  agent,
154
274
  };
@@ -1,4 +1,5 @@
1
1
  import { useCopilotChatConfiguration, useCopilotKit } from "../providers";
2
+ import { getThreadClone } from "./use-agent";
2
3
  import { ReactCustomMessageRendererPosition } from "../types/react-custom-message-renderer";
3
4
  import { Message } from "@ag-ui/core";
4
5
 
@@ -38,7 +39,11 @@ export function useRenderCustomMessages() {
38
39
  copilotkit.getRunIdForMessage(agentId, threadId, message.id) ??
39
40
  copilotkit.getRunIdsForThread(agentId, threadId).slice(-1)[0];
40
41
  const runId = resolvedRunId ?? `missing-run-id:${message.id}`;
41
- const agent = copilotkit.getAgent(agentId);
42
+ // Prefer the per-thread clone so that agent.messages reflects the actual
43
+ // conversation state (messages live on the clone, not the registry agent).
44
+ // Fall back to the registry agent when no clone exists (no threadId).
45
+ const registryAgent = copilotkit.getAgent(agentId);
46
+ const agent = getThreadClone(registryAgent, threadId) ?? registryAgent;
42
47
  if (!agent) {
43
48
  throw new Error("Agent not found");
44
49
  }