@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
@@ -97,6 +97,269 @@ export function useToast() {
97
97
  return context;
98
98
  }
99
99
 
100
+ function formatBannerMessage(message: string): string {
101
+ // Try to extract the useful message from JSON first
102
+ const jsonMatch = message.match(/'message':\s*'([^']+)'/);
103
+ if (jsonMatch) {
104
+ return jsonMatch[1];
105
+ }
106
+
107
+ // Strip technical garbage but keep the meaningful message
108
+ let cleaned = message.split(" - ")[0];
109
+ cleaned = cleaned.split(": Error code")[0];
110
+ cleaned = cleaned.replace(/:\s*\d{3}$/, "");
111
+ cleaned = cleaned.replace(/See more:.*$/g, "");
112
+ cleaned = cleaned.trim();
113
+
114
+ return cleaned || "An error occurred.";
115
+ }
116
+
117
+ function extractUrl(message: string): { url: string; text: string } | null {
118
+ const markdownMatch = /\[([^\]]+)\]\(([^)]+)\)/.exec(message);
119
+ if (markdownMatch) {
120
+ return { url: markdownMatch[2], text: "See More" };
121
+ }
122
+ const plainMatch = /(https?:\/\/[^\s)]+)/.exec(message);
123
+ if (plainMatch) {
124
+ return {
125
+ url: plainMatch[0].replace(/[.,;:'"]*$/, ""),
126
+ text: "See More",
127
+ };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function BannerErrorDisplay({
133
+ bannerError,
134
+ onDismiss,
135
+ }: {
136
+ bannerError: CopilotKitError;
137
+ onDismiss: () => void;
138
+ }) {
139
+ const [detailsExpanded, setDetailsExpanded] = useState(false);
140
+ const severity = getErrorSeverity(bannerError);
141
+ const colors = getErrorColors(severity);
142
+
143
+ // Extract optional error details attached by CopilotListeners
144
+ const details = (bannerError as any).details as
145
+ | {
146
+ code?: string;
147
+ context?: Record<string, any>;
148
+ stack?: string;
149
+ originalMessage?: string;
150
+ }
151
+ | undefined;
152
+
153
+ const link = extractUrl(bannerError.message);
154
+
155
+ return (
156
+ <div
157
+ style={{
158
+ position: "fixed",
159
+ bottom: "20px",
160
+ left: "50%",
161
+ transform: "translateX(-50%)",
162
+ zIndex: 9999,
163
+ backgroundColor: colors.background,
164
+ border: `1px solid ${colors.border}`,
165
+ borderLeft: `4px solid ${colors.border}`,
166
+ borderRadius: "8px",
167
+ padding: "12px 16px",
168
+ fontSize: "13px",
169
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
170
+ backdropFilter: "blur(8px)",
171
+ maxWidth: "min(90vw, 700px)",
172
+ width: "100%",
173
+ boxSizing: "border-box",
174
+ overflow: "hidden",
175
+ }}
176
+ >
177
+ <div
178
+ style={{
179
+ display: "flex",
180
+ justifyContent: "space-between",
181
+ alignItems: "center",
182
+ gap: "10px",
183
+ }}
184
+ >
185
+ <div
186
+ style={{
187
+ display: "flex",
188
+ alignItems: "center",
189
+ gap: "8px",
190
+ flex: 1,
191
+ minWidth: 0,
192
+ }}
193
+ >
194
+ <div
195
+ style={{
196
+ width: "12px",
197
+ height: "12px",
198
+ borderRadius: "50%",
199
+ backgroundColor: colors.border,
200
+ flexShrink: 0,
201
+ }}
202
+ />
203
+ <div
204
+ style={{
205
+ display: "flex",
206
+ alignItems: "center",
207
+ gap: "10px",
208
+ flex: 1,
209
+ minWidth: 0,
210
+ }}
211
+ >
212
+ <div
213
+ style={{
214
+ color: colors.text,
215
+ lineHeight: "1.4",
216
+ fontWeight: "400",
217
+ fontSize: "13px",
218
+ flex: 1,
219
+ wordBreak: "break-all",
220
+ overflowWrap: "break-word",
221
+ maxWidth: "550px",
222
+ overflow: "hidden",
223
+ display: "-webkit-box",
224
+ WebkitLineClamp: 10,
225
+ WebkitBoxOrient: "vertical",
226
+ }}
227
+ >
228
+ {formatBannerMessage(bannerError.message)}
229
+ </div>
230
+
231
+ {link && (
232
+ <button
233
+ onClick={() =>
234
+ window.open(link.url, "_blank", "noopener,noreferrer")
235
+ }
236
+ style={{
237
+ background: colors.border,
238
+ color: "white",
239
+ border: "none",
240
+ borderRadius: "5px",
241
+ padding: "4px 10px",
242
+ fontSize: "11px",
243
+ fontWeight: "500",
244
+ cursor: "pointer",
245
+ transition: "all 0.2s ease",
246
+ flexShrink: 0,
247
+ }}
248
+ onMouseEnter={(e) => {
249
+ e.currentTarget.style.opacity = "0.9";
250
+ e.currentTarget.style.transform = "translateY(-1px)";
251
+ }}
252
+ onMouseLeave={(e) => {
253
+ e.currentTarget.style.opacity = "1";
254
+ e.currentTarget.style.transform = "translateY(0)";
255
+ }}
256
+ >
257
+ {link.text}
258
+ </button>
259
+ )}
260
+
261
+ {details && (
262
+ <button
263
+ onClick={() => setDetailsExpanded(!detailsExpanded)}
264
+ style={{
265
+ background: "transparent",
266
+ border: `1px solid ${colors.border}`,
267
+ borderRadius: "5px",
268
+ padding: "4px 10px",
269
+ fontSize: "11px",
270
+ fontWeight: "500",
271
+ cursor: "pointer",
272
+ color: colors.text,
273
+ flexShrink: 0,
274
+ transition: "all 0.2s ease",
275
+ }}
276
+ onMouseEnter={(e) => {
277
+ e.currentTarget.style.background = "rgba(0, 0, 0, 0.05)";
278
+ }}
279
+ onMouseLeave={(e) => {
280
+ e.currentTarget.style.background = "transparent";
281
+ }}
282
+ >
283
+ {detailsExpanded ? "Hide Details" : "Show Details"}
284
+ </button>
285
+ )}
286
+ </div>
287
+ </div>
288
+ <button
289
+ onClick={onDismiss}
290
+ style={{
291
+ background: "transparent",
292
+ border: "none",
293
+ color: colors.text,
294
+ cursor: "pointer",
295
+ padding: "2px",
296
+ borderRadius: "3px",
297
+ fontSize: "14px",
298
+ lineHeight: "1",
299
+ opacity: 0.6,
300
+ transition: "all 0.2s ease",
301
+ flexShrink: 0,
302
+ }}
303
+ title="Dismiss"
304
+ onMouseEnter={(e) => {
305
+ e.currentTarget.style.opacity = "1";
306
+ e.currentTarget.style.background = "rgba(0, 0, 0, 0.05)";
307
+ }}
308
+ onMouseLeave={(e) => {
309
+ e.currentTarget.style.opacity = "0.6";
310
+ e.currentTarget.style.background = "transparent";
311
+ }}
312
+ >
313
+ x
314
+ </button>
315
+ </div>
316
+
317
+ {detailsExpanded && details && (
318
+ <div
319
+ style={{
320
+ marginTop: "10px",
321
+ padding: "10px",
322
+ background: "rgba(0, 0, 0, 0.04)",
323
+ borderRadius: "6px",
324
+ fontSize: "11px",
325
+ fontFamily: "monospace",
326
+ color: colors.text,
327
+ lineHeight: "1.5",
328
+ maxHeight: "200px",
329
+ overflowY: "auto",
330
+ whiteSpace: "pre-wrap",
331
+ wordBreak: "break-all",
332
+ }}
333
+ >
334
+ {details.code && (
335
+ <div>
336
+ <strong>Code:</strong> {details.code}
337
+ </div>
338
+ )}
339
+ {details.originalMessage && (
340
+ <div style={{ marginTop: "4px" }}>
341
+ <strong>Message:</strong> {details.originalMessage}
342
+ </div>
343
+ )}
344
+ {details.context && Object.keys(details.context).length > 0 && (
345
+ <div style={{ marginTop: "4px" }}>
346
+ <strong>Context:</strong>{" "}
347
+ {JSON.stringify(details.context, null, 2)}
348
+ </div>
349
+ )}
350
+ {details.stack && (
351
+ <div style={{ marginTop: "4px", opacity: 0.7 }}>
352
+ <strong>Stack:</strong>
353
+ {"\n"}
354
+ {details.stack}
355
+ </div>
356
+ )}
357
+ </div>
358
+ )}
359
+ </div>
360
+ );
361
+ }
362
+
100
363
  export function ToastProvider({
101
364
  enabled,
102
365
  children,
@@ -169,200 +432,12 @@ export function ToastProvider({
169
432
  return (
170
433
  <ToastContext.Provider value={value}>
171
434
  {/* Banner Error Display */}
172
- {bannerError &&
173
- (() => {
174
- const severity = getErrorSeverity(bannerError);
175
- const colors = getErrorColors(severity);
176
-
177
- return (
178
- <div
179
- style={{
180
- position: "fixed",
181
- bottom: "20px",
182
- left: "50%",
183
- transform: "translateX(-50%)",
184
- zIndex: 9999,
185
- backgroundColor: colors.background,
186
- border: `1px solid ${colors.border}`,
187
- borderLeft: `4px solid ${colors.border}`,
188
- borderRadius: "8px",
189
- padding: "12px 16px",
190
- fontSize: "13px",
191
- boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
192
- backdropFilter: "blur(8px)",
193
- maxWidth: "min(90vw, 700px)",
194
- width: "100%",
195
- boxSizing: "border-box",
196
- overflow: "hidden",
197
- }}
198
- >
199
- <div
200
- style={{
201
- display: "flex",
202
- justifyContent: "space-between",
203
- alignItems: "center",
204
- gap: "10px",
205
- }}
206
- >
207
- <div
208
- style={{
209
- display: "flex",
210
- alignItems: "center",
211
- gap: "8px",
212
- flex: 1,
213
- minWidth: 0,
214
- }}
215
- >
216
- <div
217
- style={{
218
- width: "12px",
219
- height: "12px",
220
- borderRadius: "50%",
221
- backgroundColor: colors.border,
222
- flexShrink: 0,
223
- }}
224
- />
225
- <div
226
- style={{
227
- display: "flex",
228
- alignItems: "center",
229
- gap: "10px",
230
- flex: 1,
231
- minWidth: 0,
232
- }}
233
- >
234
- <div
235
- style={{
236
- color: colors.text,
237
- lineHeight: "1.4",
238
- fontWeight: "400",
239
- fontSize: "13px",
240
- flex: 1,
241
- wordBreak: "break-all",
242
- overflowWrap: "break-word",
243
- maxWidth: "550px",
244
- overflow: "hidden",
245
- display: "-webkit-box",
246
- WebkitLineClamp: 10,
247
- WebkitBoxOrient: "vertical",
248
- }}
249
- >
250
- {(() => {
251
- let message = bannerError.message;
252
-
253
- // Try to extract the useful message from JSON first
254
- const jsonMatch = message.match(
255
- /'message':\s*'([^']+)'/,
256
- );
257
- if (jsonMatch) {
258
- return jsonMatch[1]; // Return the actual error message
259
- }
260
-
261
- // Strip technical garbage but keep the meaningful message
262
- message = message.split(" - ")[0]; // Remove everything after " - {"
263
- message = message.split(": Error code")[0]; // Remove ": Error code: 401"
264
- message = message.replace(/:\s*\d{3}$/, ""); // Remove trailing ": 401"
265
- message = message.replace(/See more:.*$/g, ""); // Remove "See more" links
266
- message = message.trim();
267
-
268
- // If it's still garbage (contains { or '), use fallback
269
- // if (message.includes("{") || message.includes("'")) {
270
- // return "Configuration error.... Please check your setup.";
271
- // }
272
-
273
- return message || "Configuration error occurred.";
274
- })()}
275
- </div>
276
-
277
- {(() => {
278
- const message = bannerError.message;
279
- const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
280
- const plainUrlRegex = /(https?:\/\/[^\s)]+)/g;
281
-
282
- // Extract the first URL found
283
- let url = null;
284
- let buttonText = "See More";
285
-
286
- // Check for markdown links first
287
- const markdownMatch = markdownLinkRegex.exec(message);
288
- if (markdownMatch) {
289
- url = markdownMatch[2];
290
- buttonText = "See More";
291
- } else {
292
- // Check for plain URLs
293
- const urlMatch = plainUrlRegex.exec(message);
294
- if (urlMatch) {
295
- url = urlMatch[0].replace(/[.,;:'"]*$/, ""); // Remove trailing punctuation
296
- buttonText = "See More";
297
- }
298
- }
299
-
300
- if (!url) return null;
301
-
302
- return (
303
- <button
304
- onClick={() =>
305
- window.open(url, "_blank", "noopener,noreferrer")
306
- }
307
- style={{
308
- background: colors.border,
309
- color: "white",
310
- border: "none",
311
- borderRadius: "5px",
312
- padding: "4px 10px",
313
- fontSize: "11px",
314
- fontWeight: "500",
315
- cursor: "pointer",
316
- transition: "all 0.2s ease",
317
- flexShrink: 0,
318
- }}
319
- onMouseEnter={(e) => {
320
- e.currentTarget.style.opacity = "0.9";
321
- e.currentTarget.style.transform =
322
- "translateY(-1px)";
323
- }}
324
- onMouseLeave={(e) => {
325
- e.currentTarget.style.opacity = "1";
326
- e.currentTarget.style.transform = "translateY(0)";
327
- }}
328
- >
329
- {buttonText}
330
- </button>
331
- );
332
- })()}
333
- </div>
334
- </div>
335
- <button
336
- onClick={() => setBannerError(null)}
337
- style={{
338
- background: "transparent",
339
- border: "none",
340
- color: colors.text,
341
- cursor: "pointer",
342
- padding: "2px",
343
- borderRadius: "3px",
344
- fontSize: "14px",
345
- lineHeight: "1",
346
- opacity: 0.6,
347
- transition: "all 0.2s ease",
348
- flexShrink: 0,
349
- }}
350
- title="Dismiss"
351
- onMouseEnter={(e) => {
352
- e.currentTarget.style.opacity = "1";
353
- e.currentTarget.style.background = "rgba(0, 0, 0, 0.05)";
354
- }}
355
- onMouseLeave={(e) => {
356
- e.currentTarget.style.opacity = "0.6";
357
- e.currentTarget.style.background = "transparent";
358
- }}
359
- >
360
- ×
361
- </button>
362
- </div>
363
- </div>
364
- );
365
- })()}
435
+ {bannerError && (
436
+ <BannerErrorDisplay
437
+ bannerError={bannerError}
438
+ onDismiss={() => setBannerError(null)}
439
+ />
440
+ )}
366
441
 
367
442
  {/* Toast Display - Deprecated: All errors now show as banners */}
368
443
  {children}
@@ -178,18 +178,32 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
178
178
  });
179
179
  });
180
180
 
181
- it("does not call connectAgent when threadId matches agent's threadId", () => {
181
+ it("does not call connectAgent when threadId matches (same agent instance, no re-render)", async () => {
182
+ // useAgent now returns a per-thread clone, so the wrapper guards via
183
+ // lastConnectedAgentRef: connect fires once per agent instance, not once
184
+ // per render. After the first connect, further re-renders with the same
185
+ // agent do not trigger another connect.
182
186
  mockRuntimeConnectionStatus =
183
187
  CopilotKitCoreRuntimeConnectionStatus.Connected;
184
- mockAgent.threadId = "config-thread-id"; // same as mockConfigThreadId
188
+ mockAgent.threadId = "config-thread-id";
185
189
  applyMocks();
186
190
 
187
- renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
191
+ const { rerender } = renderHook(() => useCopilotChatInternal(), {
192
+ wrapper: createWrapper(),
193
+ });
188
194
 
189
- expect(mockConnectAgent).not.toHaveBeenCalled();
195
+ await vi.waitFor(() => {
196
+ expect(mockConnectAgent).toHaveBeenCalledTimes(1);
197
+ });
198
+
199
+ // Re-render with same agent — should NOT connect again
200
+ rerender();
201
+ await vi.waitFor(() => {
202
+ expect(mockConnectAgent).toHaveBeenCalledTimes(1);
203
+ });
190
204
  });
191
205
 
192
- it("does not call connectAgent when config threadId is missing", () => {
206
+ it("calls connectAgent when config threadId is missing", async () => {
193
207
  mockRuntimeConnectionStatus =
194
208
  CopilotKitCoreRuntimeConnectionStatus.Connected;
195
209
  mockConfigThreadId = undefined;
@@ -197,10 +211,12 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
197
211
 
198
212
  renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
199
213
 
200
- expect(mockConnectAgent).not.toHaveBeenCalled();
214
+ await vi.waitFor(() => {
215
+ expect(mockConnectAgent).toHaveBeenCalledTimes(1);
216
+ });
201
217
  });
202
218
 
203
- it("calls connectAgent when all guard conditions are met", async () => {
219
+ it("calls connectAgent when status is Connected and threadIds differ", async () => {
204
220
  mockRuntimeConnectionStatus =
205
221
  CopilotKitCoreRuntimeConnectionStatus.Connected;
206
222
  mockAgent.threadId = "old-thread-id"; // differs from config
@@ -214,18 +230,13 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
214
230
  });
215
231
  });
216
232
 
217
- it("sets agent.threadId to config threadId before calling connectAgent", async () => {
218
- mockRuntimeConnectionStatus =
219
- CopilotKitCoreRuntimeConnectionStatus.Connected;
220
- mockAgent.threadId = "old-thread-id";
233
+ it("passes config threadId to useAgent", () => {
221
234
  applyMocks();
222
235
 
223
236
  renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
224
237
 
225
- await vi.waitFor(() => {
226
- expect(mockConnectAgent).toHaveBeenCalledTimes(1);
227
- });
228
-
229
- expect(mockAgent.threadId).toBe("config-thread-id");
238
+ expect(vi.mocked(useAgent)).toHaveBeenCalledWith(
239
+ expect.objectContaining({ threadId: "config-thread-id" }),
240
+ );
230
241
  });
231
242
  });
@@ -337,7 +337,17 @@ export function useCopilotChatInternal({
337
337
 
338
338
  // Apply priority: props > existing config > defaults
339
339
  const resolvedAgentId = existingConfig?.agentId ?? "default";
340
- const { agent } = useAgent({ agentId: resolvedAgentId });
340
+ const { agent } = useAgent({
341
+ agentId: resolvedAgentId,
342
+ threadId: existingConfig?.threadId,
343
+ });
344
+
345
+ // Track the last agent instance we called connect() on. Without this,
346
+ // connect() fires on every render where status is Connected — including
347
+ // unrelated context re-renders and StrictMode double-invocations.
348
+ // The ref is reset in the cleanup so that remounts (StrictMode, real
349
+ // unmount/remount) always trigger a fresh connect.
350
+ const lastConnectedAgentRef = useRef<AbstractAgent | null>(null);
341
351
 
342
352
  useEffect(() => {
343
353
  let detached = false;
@@ -372,18 +382,19 @@ export function useCopilotChatInternal({
372
382
  };
373
383
  if (
374
384
  agent &&
375
- existingConfig?.threadId &&
376
- agent.threadId !== existingConfig.threadId &&
385
+ agent !== lastConnectedAgentRef.current &&
377
386
  copilotkit.runtimeConnectionStatus ===
378
387
  CopilotKitCoreRuntimeConnectionStatus.Connected
379
388
  ) {
380
- agent.threadId = existingConfig.threadId;
389
+ lastConnectedAgentRef.current = agent;
381
390
  connect(agent);
382
391
  }
383
392
  return () => {
384
393
  // Abort the HTTP request and detach the active run.
385
394
  // This is critical for React StrictMode which unmounts+remounts in dev,
386
395
  // preventing duplicate /connect requests from reaching the server.
396
+ // Reset the ref so remounts always trigger a fresh connect.
397
+ lastConnectedAgentRef.current = null;
387
398
  detached = true;
388
399
  connectAbortController.abort();
389
400
  agent?.detachActiveRun();