@copilotkit/react-core 1.55.0-next.8 → 1.55.0

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 (94) hide show
  1. package/CHANGELOG.md +48 -5
  2. package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-BY5S1-0P.mjs} +2772 -858
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BuhSUZHb.d.mts} +230 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-Bz5-ImDl.cjs} +2776 -832
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-dwDWYpya.d.cts} +230 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  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 +1624 -396
  17. package/dist/index.umd.js.map +1 -1
  18. package/dist/v2/index.cjs +13 -1
  19. package/dist/v2/index.css +1 -1
  20. package/dist/v2/index.d.cts +3 -3
  21. package/dist/v2/index.d.mts +3 -3
  22. package/dist/v2/index.mjs +3 -2
  23. package/dist/v2/index.umd.js +2746 -790
  24. package/dist/v2/index.umd.js.map +1 -1
  25. package/package.json +62 -54
  26. package/scripts/scope-preflight.mjs +1 -2
  27. package/src/components/CopilotListeners.tsx +41 -8
  28. package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
  29. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  30. package/src/components/copilot-provider/copilotkit.tsx +3 -3
  31. package/src/components/toast/toast-provider.tsx +269 -194
  32. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
  33. package/src/hooks/use-copilot-chat_internal.ts +15 -4
  34. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  35. package/src/v2/__tests__/utils/test-helpers.tsx +107 -7
  36. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  37. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  38. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  39. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  40. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  41. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  42. package/src/v2/components/chat/CopilotChat.tsx +197 -52
  43. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  44. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  45. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  46. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  47. package/src/v2/components/chat/CopilotChatMessageView.tsx +260 -151
  48. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  49. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  50. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  51. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  52. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  53. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  54. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  55. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  56. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
  57. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
  58. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +60 -3
  59. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  60. package/src/v2/components/chat/index.ts +9 -0
  61. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  62. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
  63. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
  64. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  65. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
  66. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  67. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
  68. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  69. package/src/v2/hooks/index.ts +5 -0
  70. package/src/v2/hooks/use-agent.tsx +220 -15
  71. package/src/v2/hooks/use-attachments.tsx +269 -0
  72. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  73. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  74. package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
  75. package/src/v2/hooks/use-threads.tsx +35 -15
  76. package/src/v2/index.ts +5 -1
  77. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  78. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  79. package/src/v2/lib/processPartialHtml.ts +45 -0
  80. package/src/v2/lib/slots.tsx +42 -1
  81. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  82. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  83. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  84. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  85. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  86. package/src/v2/providers/index.ts +7 -0
  87. package/src/v2/styles/globals.css +2 -1
  88. package/src/v2/types/index.ts +1 -0
  89. package/src/v2/types/sandbox-function.ts +11 -0
  90. package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
  91. package/dist/copilotkit-DBzgOMby.d.cts.map +0 -1
  92. package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
  93. package/dist/copilotkit-Dy5w3qEV.d.mts.map +0 -1
  94. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -0,0 +1,138 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import { CopilotChat } from "../CopilotChat";
5
+ import { useAgent } from "../../../hooks/use-agent";
6
+ import { useCopilotKit } from "../../../providers/CopilotKitProvider";
7
+ import { useCopilotChatConfiguration } from "../../../providers/CopilotChatConfigurationProvider";
8
+ import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
9
+ import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
10
+
11
+ // Mock useAgent to inspect the props it receives
12
+ vi.mock("../../../hooks/use-agent", () => ({
13
+ useAgent: vi.fn(() => ({
14
+ agent: new MockStepwiseAgent(),
15
+ })),
16
+ }));
17
+
18
+ vi.mock("../../../providers/CopilotKitProvider", () => ({
19
+ useCopilotKit: vi.fn(),
20
+ useLicenseContext: vi.fn(() => ({
21
+ checkFeature: () => true,
22
+ })),
23
+ }));
24
+
25
+ vi.mock(
26
+ "../../../providers/CopilotChatConfigurationProvider",
27
+ async (importOriginal) => {
28
+ const actual =
29
+ await importOriginal<
30
+ typeof import("../../../providers/CopilotChatConfigurationProvider")
31
+ >();
32
+ return {
33
+ ...actual,
34
+ useCopilotChatConfiguration: vi.fn(() => undefined),
35
+ CopilotChatConfigurationProvider: ({
36
+ children,
37
+ }: {
38
+ children: React.ReactNode;
39
+ }) => <>{children}</>,
40
+ };
41
+ },
42
+ );
43
+
44
+ // Mock suggestions hook
45
+ vi.mock("../../../hooks/use-suggestions", () => ({
46
+ useSuggestions: vi.fn(() => ({ suggestions: [] })),
47
+ }));
48
+
49
+ // Mock attachments hook
50
+ vi.mock("../../../hooks/use-attachments", () => ({
51
+ useAttachments: vi.fn(() => ({
52
+ attachments: [],
53
+ enabled: false,
54
+ dragOver: false,
55
+ fileInputRef: { current: null },
56
+ containerRef: { current: null },
57
+ handleFileUpload: vi.fn(),
58
+ handleDragOver: vi.fn(),
59
+ handleDragLeave: vi.fn(),
60
+ handleDrop: vi.fn(),
61
+ removeAttachment: vi.fn(),
62
+ consumeAttachments: vi.fn(() => []),
63
+ })),
64
+ }));
65
+
66
+ const mockUseAgent = useAgent as ReturnType<typeof vi.fn>;
67
+ const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
68
+
69
+ /** Factory for the mock return value of useCopilotKit in CopilotChat tests */
70
+ function createMockChatContext(agent: MockStepwiseAgent) {
71
+ return {
72
+ copilotkit: {
73
+ getAgent: () => agent,
74
+ runtimeUrl: "http://localhost:3000/api/copilot",
75
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
76
+ runtimeTransport: "rest",
77
+ headers: {},
78
+ agents: { [String(agent.agentId)]: agent },
79
+ connectAgent: vi.fn().mockResolvedValue(undefined),
80
+ subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })),
81
+ audioFileTranscriptionEnabled: false,
82
+ },
83
+ executingToolCallIds: new Set(),
84
+ };
85
+ }
86
+
87
+ describe("CopilotChat throttleMs prop", () => {
88
+ let mockAgent: MockStepwiseAgent;
89
+
90
+ beforeEach(() => {
91
+ mockAgent = new MockStepwiseAgent();
92
+ mockAgent.agentId = "default";
93
+
94
+ mockUseAgent.mockReturnValue({ agent: mockAgent });
95
+ mockUseCopilotKit.mockReturnValue(createMockChatContext(mockAgent));
96
+ });
97
+
98
+ afterEach(() => {
99
+ vi.restoreAllMocks();
100
+ });
101
+
102
+ it("passes throttleMs prop to useAgent", () => {
103
+ render(<CopilotChat throttleMs={500} />);
104
+
105
+ expect(mockUseAgent).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ throttleMs: 500,
108
+ }),
109
+ );
110
+ });
111
+
112
+ it("passes undefined throttleMs when prop is not set", () => {
113
+ render(<CopilotChat />);
114
+
115
+ expect(mockUseAgent).toHaveBeenCalledWith(
116
+ expect.objectContaining({
117
+ throttleMs: undefined,
118
+ }),
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("throttleMs type inheritance", () => {
124
+ it("CopilotSidebarProps includes throttleMs via CopilotChatProps", () => {
125
+ // Type-level assertion — if this compiles, the type includes throttleMs.
126
+ const sidebarProps: import("../CopilotSidebar").CopilotSidebarProps = {
127
+ throttleMs: 1000,
128
+ };
129
+ expect(sidebarProps.throttleMs).toBe(1000);
130
+ });
131
+
132
+ it("CopilotPopupProps includes throttleMs via CopilotChatProps", () => {
133
+ const popupProps: import("../CopilotPopup").CopilotPopupProps = {
134
+ throttleMs: 2000,
135
+ };
136
+ expect(popupProps.throttleMs).toBe(2000);
137
+ });
138
+ });
@@ -77,3 +77,12 @@ export {
77
77
  export { CopilotSidebar, type CopilotSidebarProps } from "./CopilotSidebar";
78
78
 
79
79
  export { CopilotPopup, type CopilotPopupProps } from "./CopilotPopup";
80
+
81
+ export { CopilotChatAttachmentQueue } from "./CopilotChatAttachmentQueue";
82
+ export { CopilotChatAttachmentRenderer } from "./CopilotChatAttachmentRenderer";
83
+
84
+ export type {
85
+ Attachment,
86
+ AttachmentsConfig,
87
+ AttachmentModality,
88
+ } from "@copilotkit/shared";
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Provides the scroll container element to child components that need it for
5
+ * virtualization. Set by CopilotChatView.ScrollView; consumed by
6
+ * CopilotChatMessageView to feed useVirtualizer's getScrollElement.
7
+ *
8
+ * Carries the element itself (not a ref) so that context consumers re-render
9
+ * reactively when the scroll container is first mounted.
10
+ */
11
+ export const ScrollElementContext = React.createContext<HTMLElement | null>(
12
+ null,
13
+ );
@@ -42,8 +42,16 @@ describe("useAgentContext timing - follow-up run sees updated context", () => {
42
42
  * with no new messages — which is fine; we only need to capture context.
43
43
  */
44
44
  class ContextCapturingAgent extends MockStepwiseAgent {
45
+ // Shared so the clone and original both see the captured contexts
45
46
  public contextPerRun: Context[][] = [];
46
47
 
48
+ clone(): this {
49
+ const cloned = super.clone();
50
+ (cloned as unknown as ContextCapturingAgent).contextPerRun =
51
+ this.contextPerRun;
52
+ return cloned;
53
+ }
54
+
47
55
  async runAgent(
48
56
  parameters?: RunAgentParameters,
49
57
  subscriber?: AgentSubscriber,
@@ -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
+ });