@copilotkit/react-core 1.56.5 → 1.57.0-canary.1778082736

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 (47) hide show
  1. package/dist/{copilotkit-DFaI4j2r.d.mts → copilotkit-BNlJq5UO.d.mts} +60 -6
  2. package/dist/copilotkit-BNlJq5UO.d.mts.map +1 -0
  3. package/dist/{copilotkit-DMFu29Kx.cjs → copilotkit-B_k0HSNz.cjs} +322 -175
  4. package/dist/copilotkit-B_k0HSNz.cjs.map +1 -0
  5. package/dist/{copilotkit-Dg4r4Gi_.d.cts → copilotkit-DgC5oCFO.d.cts} +60 -6
  6. package/dist/copilotkit-DgC5oCFO.d.cts.map +1 -0
  7. package/dist/{copilotkit-OmIUrWym.mjs → copilotkit-ak8sGvQr.mjs} +317 -176
  8. package/dist/copilotkit-ak8sGvQr.mjs.map +1 -0
  9. package/dist/index.cjs +2 -5
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1 -1
  12. package/dist/index.d.mts +1 -1
  13. package/dist/index.mjs +2 -5
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/index.umd.js +117 -172
  16. package/dist/index.umd.js.map +1 -1
  17. package/dist/v2/index.cjs +2 -1
  18. package/dist/v2/index.css +1 -1
  19. package/dist/v2/index.d.cts +2 -2
  20. package/dist/v2/index.d.mts +2 -2
  21. package/dist/v2/index.mjs +2 -2
  22. package/dist/v2/index.umd.js +332 -181
  23. package/dist/v2/index.umd.js.map +1 -1
  24. package/package.json +6 -6
  25. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +5 -6
  26. package/src/hooks/use-copilot-chat_internal.ts +0 -1
  27. package/src/v2/components/chat/CopilotChat.tsx +2 -1
  28. package/src/v2/components/chat/CopilotChatMessageView.tsx +24 -9
  29. package/src/v2/components/chat/CopilotChatView.tsx +2 -2
  30. package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +1 -3
  31. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +29 -25
  32. package/src/v2/components/chat/__tests__/MCPAppsUiMessage.e2e.test.tsx +5 -60
  33. package/src/v2/components/index.ts +1 -0
  34. package/src/v2/components/intelligence-indicator/IntelligenceIndicator.tsx +286 -0
  35. package/src/v2/components/intelligence-indicator/__tests__/IntelligenceIndicator.e2e.test.tsx +464 -0
  36. package/src/v2/components/intelligence-indicator/index.ts +2 -0
  37. package/src/v2/hooks/__tests__/use-threads.test.tsx +229 -27
  38. package/src/v2/hooks/use-agent.tsx +7 -116
  39. package/src/v2/hooks/use-render-activity-message.tsx +3 -11
  40. package/src/v2/hooks/use-render-custom-messages.tsx +1 -6
  41. package/src/v2/hooks/use-threads.tsx +7 -1
  42. package/src/v2/styles/globals.css +118 -0
  43. package/dist/copilotkit-DFaI4j2r.d.mts.map +0 -1
  44. package/dist/copilotkit-DMFu29Kx.cjs.map +0 -1
  45. package/dist/copilotkit-Dg4r4Gi_.d.cts.map +0 -1
  46. package/dist/copilotkit-OmIUrWym.mjs.map +0 -1
  47. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +0 -333
@@ -1,16 +1,38 @@
1
1
  import React from "react";
2
2
  import { act, renderHook, waitFor } from "@testing-library/react";
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
5
- import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
5
+ import {
6
+ CopilotKitCoreRuntimeConnectionStatus,
7
+ ɵMAX_SOCKET_RETRIES,
8
+ } from "@copilotkit/core";
6
9
 
7
10
  vi.mock("../../providers/CopilotKitProvider", () => ({
8
11
  useCopilotKit: vi.fn(),
9
12
  }));
10
13
 
11
14
  const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
15
+
16
+ // Shape of the mock socket exposed via the hoisted `phoenix.sockets`
17
+ // array. Defined as a named type so test-side assertions can drop the
18
+ // blanket `any[]` cast and surface socket-API typos at compile time.
19
+ interface MockChannelLike {
20
+ topic: string;
21
+ params: Record<string, unknown>;
22
+ left: boolean;
23
+ serverPush(event: string, payload: unknown): void;
24
+ }
25
+ interface MockSocketLike {
26
+ url: string;
27
+ connected: boolean;
28
+ disconnected: boolean;
29
+ channels: MockChannelLike[];
30
+ triggerError(error?: unknown): void;
31
+ triggerOpen(): void;
32
+ }
33
+
12
34
  const phoenix = vi.hoisted(() => ({
13
- sockets: [] as any[],
35
+ sockets: [] as MockSocketLike[],
14
36
  }));
15
37
 
16
38
  vi.mock("phoenix", () => {
@@ -31,13 +53,11 @@ vi.mock("phoenix", () => {
31
53
  topic: string;
32
54
  params: Record<string, unknown>;
33
55
  left = false;
34
- channels: MockChannel[] = [];
35
56
 
36
57
  private handlers = new Map<
37
58
  string,
38
59
  Array<{ ref: number; callback: (payload: unknown) => void }>
39
60
  >();
40
- private joinPush = new MockPush();
41
61
  private nextRef = 1;
42
62
 
43
63
  constructor(topic = "", params: Record<string, unknown> = {}) {
@@ -63,14 +83,24 @@ vi.mock("phoenix", () => {
63
83
  return;
64
84
  }
65
85
 
86
+ // Re-check after the early returns above: `off(event)` deletes the
87
+ // entry, so a subsequent `off(event, ref)` would otherwise hit a
88
+ // non-null assertion that lies and `.filter` on undefined.
89
+ const entries = this.handlers.get(event);
90
+ if (entries === undefined) {
91
+ return;
92
+ }
66
93
  this.handlers.set(
67
94
  event,
68
- this.handlers.get(event)!.filter((entry) => entry.ref !== ref),
95
+ entries.filter((entry) => entry.ref !== ref),
69
96
  );
70
97
  }
71
98
 
72
99
  join(): MockPush {
73
- return this.joinPush;
100
+ // Each rejoin must produce a distinct push instance — sharing
101
+ // one across joins lets stale "ok"/"error" callbacks from a
102
+ // prior join fire against a new join's listeners.
103
+ return new MockPush();
74
104
  }
75
105
 
76
106
  leave(): void {
@@ -101,10 +131,22 @@ vi.mock("phoenix", () => {
101
131
  }
102
132
 
103
133
  connect(): void {
134
+ // Phoenix sockets fire `onOpen` exactly once per WebSocket upgrade,
135
+ // and the upgrade is asynchronous. Tests must drive that transition
136
+ // explicitly via `triggerOpen()` so we exercise one open per
137
+ // connection — auto-firing here would either double-fire (when a
138
+ // test also calls `triggerOpen()`) or hide cases where production
139
+ // code forgets to await the open before joining a channel.
104
140
  this.connected = true;
105
141
  }
106
142
 
107
143
  disconnect(): void {
144
+ // Real Phoenix sockets flip `connected` back to false on disconnect —
145
+ // a mock that only sets `disconnected = true` lets a regression that
146
+ // forgets to clear `connected` slip through, since assertions like
147
+ // `socket.connected === false` would be vacuously satisfied by the
148
+ // initial value but never re-checked after a reconnect cycle.
149
+ this.connected = false;
108
150
  this.disconnected = true;
109
151
  }
110
152
 
@@ -139,9 +181,12 @@ vi.mock("phoenix", () => {
139
181
  });
140
182
 
141
183
  const fetchMock = vi.fn();
142
- globalThis.fetch = fetchMock;
184
+ // Use `vi.stubGlobal` so the original `fetch` is restored automatically
185
+ // by `vi.unstubAllGlobals()` below — direct `globalThis.fetch = ...`
186
+ // assignment leaks the mock across test files in the same vitest worker.
187
+ vi.stubGlobal("fetch", fetchMock);
143
188
 
144
- function getMockSockets(): any[] {
189
+ function getMockSockets(): MockSocketLike[] {
145
190
  return phoenix.sockets;
146
191
  }
147
192
 
@@ -154,6 +199,8 @@ function setupCopilotKit(runtimeUrl = "http://localhost:4000") {
154
199
  intelligence: {
155
200
  wsUrl: "ws://localhost:4000/client",
156
201
  },
202
+ registerThreadStore: vi.fn(),
203
+ unregisterThreadStore: vi.fn(),
157
204
  },
158
205
  });
159
206
  }
@@ -198,9 +245,20 @@ describe("useThreads", () => {
198
245
  beforeEach(() => {
199
246
  phoenix.sockets.splice(0);
200
247
  fetchMock.mockReset();
248
+ // Reset before re-priming. setupCopilotKit() uses mockReturnValue, so a
249
+ // future test that uses mockReturnValueOnce would otherwise leak any
250
+ // un-consumed queued returns into the next test.
251
+ mockUseCopilotKit.mockReset();
201
252
  setupCopilotKit();
202
253
  });
203
254
 
255
+ afterAll(() => {
256
+ // Pair with `vi.stubGlobal("fetch", fetchMock)` above. Without this
257
+ // restoration the mock leaks into any sibling test file that runs in
258
+ // the same vitest worker and assumes a real `fetch`.
259
+ vi.unstubAllGlobals();
260
+ });
261
+
204
262
  it("fetches threads and subscribes to the user metadata channel", async () => {
205
263
  fetchMock
206
264
  .mockReturnValueOnce(
@@ -323,6 +381,9 @@ describe("useThreads", () => {
323
381
  await waitFor(() => {
324
382
  expect(result.current.threads).toHaveLength(1);
325
383
  });
384
+ // Identity-check the remaining thread so a regression that removes
385
+ // the wrong thread (e.g. a swapped index) is caught.
386
+ expect(result.current.threads[0].id).toBe("t-1");
326
387
  });
327
388
 
328
389
  it("renames a thread through the runtime contract", async () => {
@@ -343,10 +404,18 @@ describe("useThreads", () => {
343
404
  await result.current.renameThread("t-1", "Renamed");
344
405
  });
345
406
 
346
- const [url, options] = fetchMock.mock.calls[2];
347
- expect(url).toContain("/threads/t-1");
348
- expect(options.method).toBe("PATCH");
349
- expect(JSON.parse(options.body)).toMatchObject({
407
+ // Find the PATCH call by URL+method rather than a hardcoded index —
408
+ // a future change to the fetch order (or an extra startup fetch) must
409
+ // not silently miss the actual rename request.
410
+ const renameCall = fetchMock.mock.calls.find(
411
+ (args: unknown[]) =>
412
+ typeof args[0] === "string" &&
413
+ (args[0] as string).includes("/threads/t-1") &&
414
+ (args[1] as { method?: string } | undefined)?.method === "PATCH",
415
+ );
416
+ expect(renameCall).toBeDefined();
417
+ const [, renameOptions] = renameCall!;
418
+ expect(JSON.parse((renameOptions as { body: string }).body)).toMatchObject({
350
419
  agentId: "agent-1",
351
420
  name: "Renamed",
352
421
  });
@@ -372,17 +441,31 @@ describe("useThreads", () => {
372
441
  await result.current.deleteThread("t-1");
373
442
  });
374
443
 
375
- expect(fetchMock.mock.calls[2][0]).toContain("/threads/t-2/archive");
376
- expect(fetchMock.mock.calls[2][1].method).toBe("POST");
377
- expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toMatchObject({
378
- agentId: "agent-1",
379
- });
380
-
381
- expect(fetchMock.mock.calls[3][0]).toContain("/threads/t-1");
382
- expect(fetchMock.mock.calls[3][1].method).toBe("DELETE");
383
- expect(JSON.parse(fetchMock.mock.calls[3][1].body)).toMatchObject({
384
- agentId: "agent-1",
385
- });
444
+ // Filter by URL+method rather than fixed indices (mirrors the rename
445
+ // test above). A future change to the startup fetch order — adding an
446
+ // /info call, splitting the join token, etc. — must not silently miss
447
+ // the actual archive/delete requests.
448
+ const archiveCall = fetchMock.mock.calls.find(
449
+ (args: unknown[]) =>
450
+ typeof args[0] === "string" &&
451
+ (args[0] as string).includes("/threads/t-2/archive") &&
452
+ (args[1] as { method?: string } | undefined)?.method === "POST",
453
+ );
454
+ expect(archiveCall).toBeDefined();
455
+ expect(
456
+ JSON.parse((archiveCall![1] as { body: string }).body),
457
+ ).toMatchObject({ agentId: "agent-1" });
458
+
459
+ const deleteCall = fetchMock.mock.calls.find(
460
+ (args: unknown[]) =>
461
+ typeof args[0] === "string" &&
462
+ (args[0] as string).includes("/threads/t-1") &&
463
+ (args[1] as { method?: string } | undefined)?.method === "DELETE",
464
+ );
465
+ expect(deleteCall).toBeDefined();
466
+ expect(JSON.parse((deleteCall![1] as { body: string }).body)).toMatchObject(
467
+ { agentId: "agent-1" },
468
+ );
386
469
  });
387
470
 
388
471
  it("exposes thread-scoped pagination properties", async () => {
@@ -414,6 +497,63 @@ describe("useThreads", () => {
414
497
  expect(typeof result.current.fetchMoreThreads).toBe("function");
415
498
  });
416
499
 
500
+ it("fetchMoreThreads fetches the next page with the cursor and appends threads", async () => {
501
+ const nextPageThreads = [
502
+ {
503
+ id: "t-3",
504
+ organizationId: "org-1",
505
+ agentId: "agent-1",
506
+ createdById: "user-1",
507
+ name: "Thread Three",
508
+ archived: false,
509
+ createdAt: "2026-01-03T00:00:00Z",
510
+ updatedAt: "2026-01-03T00:00:00Z",
511
+ },
512
+ ];
513
+
514
+ fetchMock
515
+ .mockReturnValueOnce(
516
+ jsonResponse({
517
+ threads: sampleThreads,
518
+ joinCode: "jc-1",
519
+ nextCursor: "cursor-abc",
520
+ }),
521
+ )
522
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }))
523
+ .mockReturnValueOnce(
524
+ jsonResponse({ threads: nextPageThreads, joinCode: "jc-1" }),
525
+ );
526
+
527
+ const { result } = renderHook(() => useThreads(defaultInput));
528
+
529
+ await waitFor(() => {
530
+ expect(result.current.isLoading).toBe(false);
531
+ });
532
+
533
+ expect(result.current.threads).toHaveLength(2);
534
+ expect(result.current.hasMoreThreads).toBe(true);
535
+
536
+ act(() => {
537
+ result.current.fetchMoreThreads();
538
+ });
539
+
540
+ await waitFor(() => {
541
+ expect(result.current.threads).toHaveLength(3);
542
+ });
543
+
544
+ const nextPageCall = fetchMock.mock.calls.find(
545
+ (args: unknown[]) =>
546
+ typeof args[0] === "string" &&
547
+ (args[0] as string).includes("cursor=cursor-abc"),
548
+ );
549
+ expect(nextPageCall).toBeDefined();
550
+ expect(nextPageCall![0]).toContain("agentId=agent-1");
551
+ expect(result.current.threads.map((t: { id: string }) => t.id)).toContain(
552
+ "t-3",
553
+ );
554
+ expect(result.current.threads).toHaveLength(3);
555
+ });
556
+
417
557
  it("does not expose organizationId or createdById on threads", async () => {
418
558
  fetchMock
419
559
  .mockReturnValueOnce(
@@ -455,10 +595,20 @@ describe("useThreads", () => {
455
595
  const socket = getMockSockets()[0];
456
596
  const channel = socket.channels[0];
457
597
 
598
+ // Threshold is sourced from production (ɵMAX_SOCKET_RETRIES) so a
599
+ // future change to the retry budget cannot silently desync the test.
600
+ // We fire all errors inside a single act to keep the rxjs cleanup
601
+ // synchronous with the assertions, then check the pre-threshold and
602
+ // post-threshold states by inspecting the socket between iterations.
458
603
  act(() => {
459
- for (let index = 0; index < 5; index += 1) {
604
+ for (let index = 0; index < ɵMAX_SOCKET_RETRIES - 1; index += 1) {
460
605
  socket.triggerError();
461
606
  }
607
+ // Pre-threshold: teardown must NOT be premature.
608
+ expect(channel.left).toBe(false);
609
+ expect(socket.disconnected).toBe(false);
610
+ // The Nth error crosses the threshold and triggers teardown.
611
+ socket.triggerError();
462
612
  });
463
613
 
464
614
  expect(channel.left).toBe(true);
@@ -487,6 +637,47 @@ describe("useThreads", () => {
487
637
  expect(socket.disconnected).toBe(true);
488
638
  });
489
639
 
640
+ it("registers thread store on mount and unregisters on unmount", async () => {
641
+ const registerThreadStore = vi.fn();
642
+ const unregisterThreadStore = vi.fn();
643
+ // Use mockReturnValue (not mockReturnValueOnce) so the same spies are
644
+ // returned across all renders, including the cleanup render where
645
+ // unmount triggers the effect's cleanup function.
646
+ // runtimeConnectionStatus is set explicitly to Connected — the hook
647
+ // treats anything other than Connected as "do not dispatch context",
648
+ // and we want this test to exercise a fully-wired flow.
649
+ mockUseCopilotKit.mockReturnValue({
650
+ copilotkit: {
651
+ runtimeUrl: "http://localhost:4000",
652
+ runtimeConnectionStatus:
653
+ CopilotKitCoreRuntimeConnectionStatus.Connected,
654
+ headers: { Authorization: "Bearer test-token" },
655
+ intelligence: { wsUrl: "ws://localhost:4000/client" },
656
+ registerThreadStore,
657
+ unregisterThreadStore,
658
+ },
659
+ });
660
+
661
+ fetchMock
662
+ .mockReturnValueOnce(
663
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
664
+ )
665
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
666
+
667
+ const { unmount } = renderHook(() => useThreads(defaultInput));
668
+
669
+ await waitFor(() => {
670
+ expect(registerThreadStore).toHaveBeenCalledWith(
671
+ "agent-1",
672
+ expect.objectContaining({ select: expect.any(Function) }),
673
+ );
674
+ });
675
+
676
+ unmount();
677
+
678
+ expect(unregisterThreadStore).toHaveBeenCalledWith("agent-1");
679
+ });
680
+
490
681
  it("waits for runtimeConnectionStatus=Connected before fetching /threads", async () => {
491
682
  // Start in Connecting — hook should hold off on dispatching any request
492
683
  // so the initial list fetch includes wsUrl and avoids a redundant second
@@ -498,6 +689,8 @@ describe("useThreads", () => {
498
689
  CopilotKitCoreRuntimeConnectionStatus.Connecting,
499
690
  headers: { Authorization: "Bearer test-token" },
500
691
  intelligence: undefined,
692
+ registerThreadStore: vi.fn(),
693
+ unregisterThreadStore: vi.fn(),
501
694
  },
502
695
  });
503
696
 
@@ -509,8 +702,15 @@ describe("useThreads", () => {
509
702
 
510
703
  const { result, rerender } = renderHook(() => useThreads(defaultInput));
511
704
 
512
- // Give effects a tick to settle; no fetch should occur while Connecting.
513
- await new Promise((resolve) => setTimeout(resolve, 20));
705
+ // Flush React effects + microtasks deterministically. A bare
706
+ // setTimeout(20) raced the store-effect under load on slow machines.
707
+ // Chained microtask flushes inside `act` give every queued effect a
708
+ // chance to run without depending on real-time delay.
709
+ await act(async () => {
710
+ await Promise.resolve();
711
+ await Promise.resolve();
712
+ await Promise.resolve();
713
+ });
514
714
  expect(fetchMock).not.toHaveBeenCalled();
515
715
 
516
716
  // While waiting for Connected, the hook must surface isLoading=true so
@@ -529,6 +729,8 @@ describe("useThreads", () => {
529
729
  CopilotKitCoreRuntimeConnectionStatus.Connected,
530
730
  headers: { Authorization: "Bearer test-token" },
531
731
  intelligence: { wsUrl: "ws://localhost:4000/client" },
732
+ registerThreadStore: vi.fn(),
733
+ unregisterThreadStore: vi.fn(),
532
734
  },
533
735
  });
534
736
 
@@ -1,5 +1,4 @@
1
1
  import { useCopilotKit } from "../providers/CopilotKitProvider";
2
- import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
3
2
  import { useMemo, useEffect, useReducer, useRef } from "react";
4
3
  import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
5
4
  import { AbstractAgent, HttpAgent } from "@ag-ui/client";
@@ -23,7 +22,6 @@ const ALL_UPDATES: UseAgentUpdate[] = [
23
22
 
24
23
  export interface UseAgentProps {
25
24
  agentId?: string;
26
- threadId?: string;
27
25
  updates?: UseAgentUpdate[];
28
26
  /**
29
27
  * Throttle interval (in milliseconds) for re-renders triggered by
@@ -50,80 +48,7 @@ export interface UseAgentProps {
50
48
  throttleMs?: number;
51
49
  }
52
50
 
53
- /**
54
- * Clone a registry agent for per-thread isolation.
55
- * Copies agent configuration (transport, headers, etc.) but resets conversation
56
- * state (messages, threadId, state) so each thread starts fresh.
57
- */
58
- function cloneForThread(
59
- source: AbstractAgent,
60
- threadId: string,
61
- headers: Record<string, string>,
62
- ): AbstractAgent {
63
- const clone = source.clone();
64
- if (clone === source) {
65
- throw new Error(
66
- `useAgent: ${source.constructor.name}.clone() returned the same instance. ` +
67
- `clone() must return a new, independent object.`,
68
- );
69
- }
70
- clone.threadId = threadId;
71
- clone.setMessages([]);
72
- clone.setState({});
73
- if (clone instanceof HttpAgent) {
74
- clone.headers = { ...headers };
75
- }
76
- return clone;
77
- }
78
-
79
- /**
80
- * Module-level WeakMap: registryAgent → (threadId → clone).
81
- * Shared across all useAgent() calls so that every component using the same
82
- * (agentId, threadId) pair receives the same agent instance. Using WeakMap
83
- * ensures the clone map is garbage-collected when the registry agent is
84
- * replaced (e.g. after reconnect or hot-reload).
85
- */
86
- export const globalThreadCloneMap = new WeakMap<
87
- AbstractAgent,
88
- Map<string, AbstractAgent>
89
- >();
90
-
91
- /**
92
- * Look up an existing per-thread clone without creating one.
93
- * Returns undefined when no clone has been created yet for this pair.
94
- */
95
- export function getThreadClone(
96
- registryAgent: AbstractAgent | undefined | null,
97
- threadId: string | undefined | null,
98
- ): AbstractAgent | undefined {
99
- if (!registryAgent || !threadId) return undefined;
100
- return globalThreadCloneMap.get(registryAgent)?.get(threadId);
101
- }
102
-
103
- function getOrCreateThreadClone(
104
- existing: AbstractAgent,
105
- threadId: string,
106
- headers: Record<string, string>,
107
- ): AbstractAgent {
108
- let byThread = globalThreadCloneMap.get(existing);
109
- if (!byThread) {
110
- byThread = new Map();
111
- globalThreadCloneMap.set(existing, byThread);
112
- }
113
- const cached = byThread.get(threadId);
114
- if (cached) return cached;
115
-
116
- const clone = cloneForThread(existing, threadId, headers);
117
- byThread.set(threadId, clone);
118
- return clone;
119
- }
120
-
121
- export function useAgent({
122
- agentId,
123
- threadId,
124
- updates,
125
- throttleMs,
126
- }: UseAgentProps = {}) {
51
+ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
127
52
  agentId ??= DEFAULT_AGENT_ID;
128
53
 
129
54
  const { copilotkit } = useCopilotKit();
@@ -131,12 +56,6 @@ export function useAgent({
131
56
  // subscribeToAgentWithOptions reads it from the core instance, but React needs the dep
132
57
  // to know when to re-subscribe.
133
58
  const providerThrottleMs = copilotkit.defaultThrottleMs;
134
- // Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
135
- // that useAgent() called without explicit threadId (e.g. inside a custom
136
- // message renderer) automatically uses the same per-thread clone as the
137
- // CopilotChat component it lives within.
138
- const chatConfig = useCopilotChatConfiguration();
139
- threadId ??= chatConfig?.threadId;
140
59
 
141
60
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
142
61
 
@@ -153,29 +72,11 @@ export function useAgent({
153
72
  );
154
73
 
155
74
  const agent: AbstractAgent = useMemo(() => {
156
- // Use a composite key when threadId is provided so that different threads
157
- // for the same agent get independent instances.
158
- const cacheKey = threadId ? `${agentId}:${threadId}` : agentId;
159
-
160
75
  const existing = copilotkit.getAgent(agentId);
161
76
  if (existing) {
162
- // Real agent found — clear any cached provisionals for this key and the
163
- // bare agentId key (handles the case where a provisional was created
164
- // before threadId was available, then the component re-renders with one).
165
- provisionalAgentCache.current.delete(cacheKey);
77
+ // Real agent found — clear any cached provisional for this ID
166
78
  provisionalAgentCache.current.delete(agentId);
167
-
168
- if (!threadId) {
169
- // No threadId — return the shared registry agent (original behavior)
170
- return existing;
171
- }
172
-
173
- // threadId provided — return the shared per-thread clone.
174
- // The global WeakMap ensures all components using the same
175
- // (registryAgent, threadId) pair receive the same instance, so state
176
- // mutations (addMessage, setState) are visible everywhere. The WeakMap
177
- // entry is GC-collected automatically when the registry agent is replaced.
178
- return getOrCreateThreadClone(existing, threadId, copilotkit.headers);
79
+ return existing;
179
80
  }
180
81
 
181
82
  const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
@@ -188,7 +89,7 @@ export function useAgent({
188
89
  status === CopilotKitCoreRuntimeConnectionStatus.Connecting)
189
90
  ) {
190
91
  // Return cached provisional if available (keeps reference stable)
191
- const cached = provisionalAgentCache.current.get(cacheKey);
92
+ const cached = provisionalAgentCache.current.get(agentId);
192
93
  if (cached) {
193
94
  // Update headers on the cached agent in case they changed
194
95
  cached.headers = { ...copilotkit.headers };
@@ -203,10 +104,7 @@ export function useAgent({
203
104
  });
204
105
  // Apply current headers so runs/connects inherit them
205
106
  provisional.headers = { ...copilotkit.headers };
206
- if (threadId) {
207
- provisional.threadId = threadId;
208
- }
209
- provisionalAgentCache.current.set(cacheKey, provisional);
107
+ provisionalAgentCache.current.set(agentId, provisional);
210
108
  return provisional;
211
109
  }
212
110
 
@@ -219,10 +117,7 @@ export function useAgent({
219
117
  isRuntimeConfigured &&
220
118
  status === CopilotKitCoreRuntimeConnectionStatus.Error
221
119
  ) {
222
- // Cache the provisional so that dep changes while in Error state (e.g.
223
- // headers update) return the same agent reference, matching the
224
- // Disconnected/Connecting path and preventing spurious re-subscriptions.
225
- const cached = provisionalAgentCache.current.get(cacheKey);
120
+ const cached = provisionalAgentCache.current.get(agentId);
226
121
  if (cached) {
227
122
  cached.headers = { ...copilotkit.headers };
228
123
  return cached;
@@ -234,10 +129,7 @@ export function useAgent({
234
129
  runtimeMode: "pending",
235
130
  });
236
131
  provisional.headers = { ...copilotkit.headers };
237
- if (threadId) {
238
- provisional.threadId = threadId;
239
- }
240
- provisionalAgentCache.current.set(cacheKey, provisional);
132
+ provisionalAgentCache.current.set(agentId, provisional);
241
133
  return provisional;
242
134
  }
243
135
 
@@ -256,7 +148,6 @@ export function useAgent({
256
148
  // eslint-disable-next-line react-hooks/exhaustive-deps
257
149
  }, [
258
150
  agentId,
259
- threadId,
260
151
  copilotkit.agents,
261
152
  copilotkit.runtimeConnectionStatus,
262
153
  copilotkit.runtimeUrl,
@@ -3,12 +3,10 @@ import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
3
3
  import { useCopilotKit, useCopilotChatConfiguration } from "../providers";
4
4
  import { useCallback, useMemo } from "react";
5
5
  import { ReactActivityMessageRenderer } from "../types";
6
- import { getThreadClone } from "./use-agent";
7
6
 
8
7
  export function useRenderActivityMessage() {
9
8
  const { copilotkit } = useCopilotKit();
10
- const config = useCopilotChatConfiguration();
11
- const agentId = config?.agentId ?? DEFAULT_AGENT_ID;
9
+ const agentId = useCopilotChatConfiguration()?.agentId ?? DEFAULT_AGENT_ID;
12
10
 
13
11
  const renderers = copilotkit.renderActivityMessages;
14
12
 
@@ -52,13 +50,7 @@ export function useRenderActivityMessage() {
52
50
  }
53
51
 
54
52
  const Component = renderer.render;
55
- // Prefer the per-thread clone so that handleAction in ReactSurfaceHost
56
- // calls runAgent on the same agent instance that CopilotChat renders from.
57
- // Without this, button clicks accumulate messages on the registry agent
58
- // while CopilotChat displays from the clone — responses appear to vanish.
59
- const registryAgent = copilotkit.getAgent(agentId);
60
- const agent =
61
- getThreadClone(registryAgent, config?.threadId) ?? registryAgent;
53
+ const agent = copilotkit.getAgent(agentId);
62
54
 
63
55
  return (
64
56
  <Component
@@ -70,7 +62,7 @@ export function useRenderActivityMessage() {
70
62
  />
71
63
  );
72
64
  },
73
- [agentId, config?.threadId, copilotkit, findRenderer],
65
+ [agentId, copilotkit, findRenderer],
74
66
  );
75
67
 
76
68
  return useMemo(
@@ -1,5 +1,4 @@
1
1
  import { useCopilotChatConfiguration, useCopilotKit } from "../providers";
2
- import { getThreadClone } from "./use-agent";
3
2
  import { ReactCustomMessageRendererPosition } from "../types/react-custom-message-renderer";
4
3
  import { Message } from "@ag-ui/core";
5
4
 
@@ -39,11 +38,7 @@ export function useRenderCustomMessages() {
39
38
  copilotkit.getRunIdForMessage(agentId, threadId, message.id) ??
40
39
  copilotkit.getRunIdsForThread(agentId, threadId).slice(-1)[0];
41
40
  const runId = resolvedRunId ?? `missing-run-id:${message.id}`;
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;
41
+ const agent = copilotkit.getAgent(agentId);
47
42
  if (!agent) {
48
43
  return null;
49
44
  }
@@ -253,6 +253,13 @@ export function useThreads({
253
253
  // realtime subscription or cached thread list stays usable while the
254
254
  // runtime recovers, and we don't re-trigger a fetch storm on transitions.
255
255
  const runtimeStatus = copilotkit.runtimeConnectionStatus;
256
+ useEffect(() => {
257
+ copilotkit.registerThreadStore(agentId, store);
258
+ return () => {
259
+ copilotkit.unregisterThreadStore(agentId);
260
+ };
261
+ }, [copilotkit, agentId, store]);
262
+
256
263
  useEffect(() => {
257
264
  if (!copilotkit.runtimeUrl) {
258
265
  store.setContext(null);
@@ -283,7 +290,6 @@ export function useThreads({
283
290
  headersKey,
284
291
  copilotkit.intelligence?.wsUrl,
285
292
  agentId,
286
- copilotkit.headers,
287
293
  includeArchived,
288
294
  limit,
289
295
  ]);