@copilotkit/react-core 1.55.2 → 1.55.3-canary.1776243725

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 (41) hide show
  1. package/dist/{copilotkit-Cd-NrDyp.mjs → copilotkit-Bm4ox8G0.mjs} +89 -42
  2. package/dist/copilotkit-Bm4ox8G0.mjs.map +1 -0
  3. package/dist/{copilotkit-Dgdpbqjt.cjs → copilotkit-BoOnQHlE.cjs} +93 -40
  4. package/dist/copilotkit-BoOnQHlE.cjs.map +1 -0
  5. package/dist/{copilotkit-dwDWYpya.d.cts → copilotkit-EfopO2gn.d.cts} +27 -9
  6. package/dist/copilotkit-EfopO2gn.d.cts.map +1 -0
  7. package/dist/{copilotkit-BuhSUZHb.d.mts → copilotkit-opur-20s.d.mts} +27 -9
  8. package/dist/copilotkit-opur-20s.d.mts.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.umd.js +36 -15
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/v2/index.cjs +2 -1
  16. package/dist/v2/index.d.cts +2 -2
  17. package/dist/v2/index.d.mts +2 -2
  18. package/dist/v2/index.mjs +2 -2
  19. package/dist/v2/index.umd.js +87 -40
  20. package/dist/v2/index.umd.js.map +1 -1
  21. package/package.json +6 -6
  22. package/src/components/copilot-provider/__tests__/error-visibility-prod.test.tsx +70 -0
  23. package/src/components/copilot-provider/copilot-messages.tsx +39 -24
  24. package/src/components/copilot-provider/copilotkit-props.tsx +9 -5
  25. package/src/components/copilot-provider/copilotkit.tsx +4 -1
  26. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +18 -15
  27. package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +17 -4
  28. package/src/v2/components/chat/CopilotChatUserMessage.tsx +13 -10
  29. package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +131 -5
  30. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +1 -1
  31. package/src/v2/components/chat/__tests__/CopilotChatCopyButton.clipboard.test.tsx +241 -0
  32. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +10 -10
  33. package/src/v2/hooks/__tests__/use-capabilities.test.tsx +76 -0
  34. package/src/v2/hooks/index.ts +1 -0
  35. package/src/v2/hooks/use-agent.tsx +23 -4
  36. package/src/v2/hooks/use-capabilities.tsx +25 -0
  37. package/src/v2/providers/CopilotKitProvider.tsx +6 -2
  38. package/dist/copilotkit-BuhSUZHb.d.mts.map +0 -1
  39. package/dist/copilotkit-Cd-NrDyp.mjs.map +0 -1
  40. package/dist/copilotkit-Dgdpbqjt.cjs.map +0 -1
  41. package/dist/copilotkit-dwDWYpya.d.cts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.55.2",
3
+ "version": "1.55.3-canary.1776243725",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -73,11 +73,11 @@
73
73
  "untruncate-json": "^0.0.1",
74
74
  "use-stick-to-bottom": "^1.1.1",
75
75
  "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/a2ui-renderer": "1.55.2",
77
- "@copilotkit/core": "1.55.2",
78
- "@copilotkit/runtime-client-gql": "1.55.2",
79
- "@copilotkit/shared": "1.55.2",
80
- "@copilotkit/web-inspector": "1.55.2"
76
+ "@copilotkit/a2ui-renderer": "1.55.3-canary.1776243725",
77
+ "@copilotkit/core": "1.55.3-canary.1776243725",
78
+ "@copilotkit/shared": "1.55.3-canary.1776243725",
79
+ "@copilotkit/runtime-client-gql": "1.55.3-canary.1776243725",
80
+ "@copilotkit/web-inspector": "1.55.3-canary.1776243725"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ErrorVisibility } from "@copilotkit/shared";
3
+ import { getErrorSuppression } from "../copilot-messages";
4
+
5
+ /**
6
+ * Regression tests for #2431: error visibility when showDevConsole=false.
7
+ *
8
+ * The bug: `routeError` returned early for ALL errors when `isDev` was false,
9
+ * suppressing TOAST and BANNER errors that should always reach the user.
10
+ *
11
+ * The fix: only SILENT and DEV_ONLY errors are suppressed in production;
12
+ * TOAST, BANNER, and untagged errors are always surfaced.
13
+ */
14
+ describe("getErrorSuppression — error visibility routing (#2431)", () => {
15
+ // --- Production (isDev = false) ---
16
+
17
+ it("surfaces TOAST errors in production", () => {
18
+ expect(getErrorSuppression(ErrorVisibility.TOAST, false)).toBeNull();
19
+ });
20
+
21
+ it("surfaces BANNER errors in production", () => {
22
+ expect(getErrorSuppression(ErrorVisibility.BANNER, false)).toBeNull();
23
+ });
24
+
25
+ it("suppresses DEV_ONLY errors in production", () => {
26
+ expect(getErrorSuppression(ErrorVisibility.DEV_ONLY, false)).not.toBeNull();
27
+ });
28
+
29
+ it("suppresses SILENT errors in production", () => {
30
+ expect(getErrorSuppression(ErrorVisibility.SILENT, false)).not.toBeNull();
31
+ });
32
+
33
+ it("surfaces errors with no visibility tag in production", () => {
34
+ expect(getErrorSuppression(undefined, false)).toBeNull();
35
+ });
36
+
37
+ // --- Development (isDev = true) ---
38
+
39
+ it("surfaces TOAST errors in development", () => {
40
+ expect(getErrorSuppression(ErrorVisibility.TOAST, true)).toBeNull();
41
+ });
42
+
43
+ it("surfaces BANNER errors in development", () => {
44
+ expect(getErrorSuppression(ErrorVisibility.BANNER, true)).toBeNull();
45
+ });
46
+
47
+ it("surfaces DEV_ONLY errors in development", () => {
48
+ expect(getErrorSuppression(ErrorVisibility.DEV_ONLY, true)).toBeNull();
49
+ });
50
+
51
+ it("suppresses SILENT errors in development", () => {
52
+ expect(getErrorSuppression(ErrorVisibility.SILENT, true)).not.toBeNull();
53
+ });
54
+
55
+ it("surfaces errors with no visibility tag in development", () => {
56
+ expect(getErrorSuppression(undefined, true)).toBeNull();
57
+ });
58
+
59
+ // --- Log prefix strings ---
60
+
61
+ it("returns a 'Silent Error' prefix for SILENT visibility", () => {
62
+ const prefix = getErrorSuppression(ErrorVisibility.SILENT, false);
63
+ expect(prefix).toContain("Silent");
64
+ });
65
+
66
+ it("returns a 'hidden in production' prefix for DEV_ONLY visibility", () => {
67
+ const prefix = getErrorSuppression(ErrorVisibility.DEV_ONLY, false);
68
+ expect(prefix).toContain("hidden in production");
69
+ });
70
+ });
@@ -31,6 +31,33 @@ import {
31
31
  } from "@copilotkit/shared";
32
32
  import { Suggestion } from "@copilotkit/core";
33
33
 
34
+ /**
35
+ * Determine whether a GraphQL error should be suppressed based on its visibility
36
+ * and whether the dev console is active.
37
+ *
38
+ * Returns `null` when the error should be surfaced to the UI, or a log prefix
39
+ * string when the error should be suppressed (logged to console only).
40
+ *
41
+ * Exported for unit testing.
42
+ */
43
+ export function getErrorSuppression(
44
+ visibility: ErrorVisibility | undefined,
45
+ isDev: boolean,
46
+ ): string | null {
47
+ // Silent errors are always suppressed
48
+ if (visibility === ErrorVisibility.SILENT) {
49
+ return "CopilotKit Silent Error:";
50
+ }
51
+
52
+ // DEV_ONLY errors are suppressed in production
53
+ if (!isDev && visibility === ErrorVisibility.DEV_ONLY) {
54
+ return "CopilotKit Error (hidden in production):";
55
+ }
56
+
57
+ // All other visibilities (TOAST, BANNER, undefined) are always surfaced
58
+ return null;
59
+ }
60
+
34
61
  // Helper to determine if error should show as banner based on visibility and legacy patterns
35
62
  function shouldShowAsBanner(gqlError: GraphQLError): boolean {
36
63
  const extensions = gqlError.extensions;
@@ -224,20 +251,13 @@ export function CopilotMessages({ children }: { children: ReactNode }) {
224
251
  const visibility = extensions?.visibility as ErrorVisibility;
225
252
  const isDev = shouldShowDevConsole(showDevConsole);
226
253
 
227
- if (!isDev) {
228
- console.error(
229
- "CopilotKit Error (hidden in production):",
230
- gqlError.message,
231
- );
232
- return;
233
- }
234
-
235
- // Silent errors - just log
236
- if (visibility === ErrorVisibility.SILENT) {
237
- console.error("CopilotKit Silent Error:", gqlError.message);
254
+ const suppression = getErrorSuppression(visibility, isDev);
255
+ if (suppression) {
256
+ console.error(suppression, gqlError.message);
238
257
  return;
239
258
  }
240
259
 
260
+ // TOAST and BANNER errors are always surfaced, even in production
241
261
  // All other errors (including DEV_ONLY) show as banners for consistency
242
262
  const ckError = createStructuredError(gqlError);
243
263
  if (ckError) {
@@ -259,19 +279,14 @@ export function CopilotMessages({ children }: { children: ReactNode }) {
259
279
  // Process all errors as banners
260
280
  graphQLErrors.forEach(routeError);
261
281
  } else {
262
- const isDev = shouldShowDevConsole(showDevConsole);
263
- if (!isDev) {
264
- console.error("CopilotKit Error (hidden in production):", error);
265
- } else {
266
- // Route non-GraphQL errors to banner as well
267
- const fallbackError = new CopilotKitError({
268
- message: error?.message || String(error),
269
- code: CopilotKitErrorCode.UNKNOWN,
270
- });
271
- setBannerError(fallbackError);
272
- // Trace the non-GraphQL error
273
- traceUIError(fallbackError, error);
274
- }
282
+ // Non-GraphQL errors are always surfaced to the user
283
+ const fallbackError = new CopilotKitError({
284
+ message: error?.message || String(error),
285
+ code: CopilotKitErrorCode.UNKNOWN,
286
+ });
287
+ setBannerError(fallbackError);
288
+ // Trace the non-GraphQL error
289
+ traceUIError(fallbackError, error);
275
290
  }
276
291
  },
277
292
  [setBannerError, showDevConsole, traceUIError],
@@ -68,15 +68,19 @@ export interface CopilotKitProps extends Omit<
68
68
 
69
69
  /**
70
70
  * Additional headers to be sent with the request.
71
+ * Can be a static object or a function that returns headers dynamically
72
+ * (useful for refreshing auth tokens).
71
73
  *
72
74
  * For example:
73
- * ```json
74
- * {
75
- * "Authorization": "Bearer X"
76
- * }
75
+ * ```tsx
76
+ * // Static headers
77
+ * headers={{ "Authorization": "Bearer X" }}
78
+ *
79
+ * // Dynamic headers (re-evaluated on each render)
80
+ * headers={() => ({ "Authorization": `Bearer ${getToken()}` })}
77
81
  * ```
78
82
  */
79
- headers?: Record<string, string>;
83
+ headers?: Record<string, string> | (() => Record<string, string>);
80
84
 
81
85
  /**
82
86
  * The children to be rendered within the CopilotKit.
@@ -311,7 +311,10 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
311
311
  publicApiKey: publicApiKey,
312
312
  ...(cloud ? { cloud } : {}),
313
313
  chatApiEndpoint: chatApiEndpoint,
314
- headers: props.headers || {},
314
+ headers:
315
+ typeof props.headers === "function"
316
+ ? props.headers()
317
+ : props.headers || {},
315
318
  properties: props.properties || {},
316
319
  transcribeAudioUrl: props.transcribeAudioUrl,
317
320
  textToSpeechUrl: props.textToSpeechUrl,
@@ -22,6 +22,7 @@ import {
22
22
  import { useKatexStyles } from "../../hooks/useKatexStyles";
23
23
  import { WithSlots, renderSlot } from "../../lib/slots";
24
24
  import { Streamdown } from "streamdown";
25
+ import { copyToClipboard } from "@copilotkit/shared";
25
26
  import CopilotChatToolCallsView from "./CopilotChatToolCallsView";
26
27
 
27
28
  export type CopilotChatAssistantMessageProps = WithSlots<
@@ -86,12 +87,9 @@ export function CopilotChatAssistantMessage({
86
87
  {
87
88
  onClick: async () => {
88
89
  if (message.content) {
89
- try {
90
- await navigator.clipboard.writeText(message.content);
91
- } catch (err) {
92
- console.error("Failed to copy message:", err);
93
- }
90
+ return await copyToClipboard(message.content);
94
91
  }
92
+ return false;
95
93
  },
96
94
  },
97
95
  );
@@ -275,18 +273,23 @@ export namespace CopilotChatAssistantMessage {
275
273
  };
276
274
  }, []);
277
275
 
278
- const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
279
- setCopied(true);
280
- if (timerRef.current !== null) {
281
- clearTimeout(timerRef.current);
276
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
277
+ let success = false;
278
+ if (onClick) {
279
+ // onClick may return a boolean indicating copy success
280
+ const result = await Promise.resolve(onClick(event));
281
+ success = result === true;
282
282
  }
283
- timerRef.current = setTimeout(() => {
284
- timerRef.current = null;
285
- setCopied(false);
286
- }, 2000);
287
283
 
288
- if (onClick) {
289
- onClick(event);
284
+ if (success) {
285
+ setCopied(true);
286
+ if (timerRef.current !== null) {
287
+ clearTimeout(timerRef.current);
288
+ }
289
+ timerRef.current = setTimeout(() => {
290
+ timerRef.current = null;
291
+ setCopied(false);
292
+ }, 2000);
290
293
  }
291
294
  };
292
295
 
@@ -71,18 +71,31 @@ export function CopilotChatReasoningMessage({
71
71
  return () => clearInterval(timer);
72
72
  }, [isStreaming]);
73
73
 
74
- // Default to open while streaming, auto-collapse when streaming ends
74
+ // Default to open while streaming, auto-collapse when streaming ends.
75
+ // Track whether the user has manually toggled so auto-collapse doesn't
76
+ // override their explicit intent (prevents flaky test failures on CI
77
+ // where async forceUpdate timing can race with click handlers).
75
78
  const [isOpen, setIsOpen] = useState(isStreaming);
79
+ const userToggledRef = useRef(false);
76
80
 
77
81
  useEffect(() => {
78
82
  if (isStreaming) {
83
+ // Reset user-toggle tracking when a new streaming session starts
84
+ userToggledRef.current = false;
79
85
  setIsOpen(true);
80
- } else {
81
- // Auto-collapse when reasoning finishes
86
+ } else if (!userToggledRef.current) {
87
+ // Auto-collapse only if the user hasn't manually toggled
82
88
  setIsOpen(false);
83
89
  }
84
90
  }, [isStreaming]);
85
91
 
92
+ const handleToggle = hasContent
93
+ ? () => {
94
+ userToggledRef.current = true;
95
+ setIsOpen((prev) => !prev);
96
+ }
97
+ : undefined;
98
+
86
99
  const label = isStreaming
87
100
  ? "Thinking…"
88
101
  : `Thought for ${formatDuration(elapsed)}`;
@@ -92,7 +105,7 @@ export function CopilotChatReasoningMessage({
92
105
  label,
93
106
  hasContent,
94
107
  isStreaming,
95
- onClick: hasContent ? () => setIsOpen((prev) => !prev) : undefined,
108
+ onClick: handleToggle,
96
109
  });
97
110
 
98
111
  const boundContent = renderSlot(
@@ -18,6 +18,7 @@ import {
18
18
  type AudioInputPart,
19
19
  type VideoInputPart,
20
20
  type DocumentInputPart,
21
+ copyToClipboard,
21
22
  } from "@copilotkit/shared";
22
23
  import { CopilotChatAttachmentRenderer } from "./CopilotChatAttachmentRenderer";
23
24
 
@@ -147,12 +148,9 @@ export function CopilotChatUserMessage({
147
148
  {
148
149
  onClick: async () => {
149
150
  if (flattenedContent) {
150
- try {
151
- await navigator.clipboard.writeText(flattenedContent);
152
- } catch (err) {
153
- console.error("Failed to copy message:", err);
154
- }
151
+ return await copyToClipboard(flattenedContent);
155
152
  }
153
+ return false;
156
154
  },
157
155
  },
158
156
  );
@@ -314,12 +312,17 @@ export namespace CopilotChatUserMessage {
314
312
  const labels = config?.labels ?? CopilotChatDefaultLabels;
315
313
  const [copied, setCopied] = useState(false);
316
314
 
317
- const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
318
- setCopied(true);
319
- setTimeout(() => setCopied(false), 2000);
320
-
315
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
316
+ let success = false;
321
317
  if (onClick) {
322
- onClick(event);
318
+ // onClick may return a boolean indicating copy success
319
+ const result = await Promise.resolve(onClick(event));
320
+ success = result === true;
321
+ }
322
+
323
+ if (success) {
324
+ setCopied(true);
325
+ setTimeout(() => setCopied(false), 2000);
323
326
  }
324
327
  };
325
328
 
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect } from "react";
2
- import { screen, fireEvent, waitFor } from "@testing-library/react";
2
+ import { screen, fireEvent, waitFor, act } from "@testing-library/react";
3
3
  import { z } from "zod";
4
4
  import { defineToolCallRenderer, ReactToolCallRenderer } from "../../../types";
5
5
  import {
@@ -1060,6 +1060,128 @@ describe("CopilotChat E2E - Chat Basics and Streaming Patterns", () => {
1060
1060
  agent.complete();
1061
1061
  });
1062
1062
 
1063
+ it("should not auto-collapse when user manually toggled during streaming", async () => {
1064
+ const agent = new MockStepwiseAgent();
1065
+ renderWithCopilotKit({ agent });
1066
+
1067
+ const input = await screen.findByRole("textbox");
1068
+ fireEvent.change(input, { target: { value: "User toggle test" } });
1069
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1070
+
1071
+ await waitFor(() => {
1072
+ expect(screen.getByText("User toggle test")).toBeDefined();
1073
+ });
1074
+
1075
+ const reasoningId = testId("reasoning");
1076
+ const textId = testId("text");
1077
+
1078
+ // Start streaming reasoning — panel should auto-open
1079
+ agent.emit(runStartedEvent());
1080
+ agent.emit(reasoningStartEvent(reasoningId));
1081
+ agent.emit(reasoningMessageStartEvent(reasoningId));
1082
+ agent.emit(
1083
+ reasoningMessageContentEvent(reasoningId, "Deep analysis in progress"),
1084
+ );
1085
+
1086
+ await waitFor(() => {
1087
+ expect(screen.getByText("Thinking…")).toBeDefined();
1088
+ });
1089
+
1090
+ // Panel should be open (aria-expanded="true") while streaming
1091
+ await waitFor(() => {
1092
+ const header = screen.getByText("Thinking…");
1093
+ const button = header.closest("button");
1094
+ expect(button?.getAttribute("aria-expanded")).toBe("true");
1095
+ });
1096
+
1097
+ // User manually collapses during streaming — this sets userToggledRef
1098
+ const header = screen.getByText("Thinking…");
1099
+ const button = header.closest("button");
1100
+ act(() => {
1101
+ if (button) {
1102
+ fireEvent.click(button);
1103
+ }
1104
+ });
1105
+
1106
+ // Should now be collapsed by user action
1107
+ await waitFor(() => {
1108
+ const btn = screen.getByText("Thinking…").closest("button");
1109
+ expect(btn?.getAttribute("aria-expanded")).toBe("false");
1110
+ });
1111
+
1112
+ // Now streaming ends — because userToggledRef is true, the panel
1113
+ // should stay in whatever state the user set (collapsed).
1114
+ agent.emit(reasoningMessageEndEvent(reasoningId));
1115
+ agent.emit(reasoningEndEvent(reasoningId));
1116
+ agent.emit(textChunkEvent(textId, "Done."));
1117
+ agent.emit(runFinishedEvent());
1118
+ agent.complete();
1119
+
1120
+ // Panel should remain collapsed (not flash open then closed)
1121
+ await waitFor(() => {
1122
+ const btn = screen.getByText(/Thought for/).closest("button");
1123
+ expect(btn?.getAttribute("aria-expanded")).toBe("false");
1124
+ });
1125
+ });
1126
+
1127
+ it("should keep panel open when user re-expands during streaming", async () => {
1128
+ const agent = new MockStepwiseAgent();
1129
+ renderWithCopilotKit({ agent });
1130
+
1131
+ const input = await screen.findByRole("textbox");
1132
+ fireEvent.change(input, {
1133
+ target: { value: "Re-expand toggle test" },
1134
+ });
1135
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1136
+
1137
+ await waitFor(() => {
1138
+ expect(screen.getByText("Re-expand toggle test")).toBeDefined();
1139
+ });
1140
+
1141
+ const reasoningId = testId("reasoning");
1142
+ const textId = testId("text");
1143
+
1144
+ // Start streaming reasoning — panel auto-opens
1145
+ agent.emit(runStartedEvent());
1146
+ agent.emit(reasoningStartEvent(reasoningId));
1147
+ agent.emit(reasoningMessageStartEvent(reasoningId));
1148
+ agent.emit(reasoningMessageContentEvent(reasoningId, "Thinking hard"));
1149
+
1150
+ await waitFor(() => {
1151
+ const btn = screen.getByText("Thinking…").closest("button");
1152
+ expect(btn?.getAttribute("aria-expanded")).toBe("true");
1153
+ });
1154
+
1155
+ // User collapses, then re-expands (both set userToggledRef = true)
1156
+ const headerEl = screen.getByText("Thinking…");
1157
+ const btn = headerEl.closest("button");
1158
+ act(() => {
1159
+ if (btn) {
1160
+ fireEvent.click(btn); // collapse
1161
+ fireEvent.click(btn); // re-expand
1162
+ }
1163
+ });
1164
+
1165
+ await waitFor(() => {
1166
+ const b = screen.getByText("Thinking…").closest("button");
1167
+ expect(b?.getAttribute("aria-expanded")).toBe("true");
1168
+ });
1169
+
1170
+ // Streaming ends — because userToggledRef is true, panel should
1171
+ // stay in the user's chosen state (open).
1172
+ agent.emit(reasoningMessageEndEvent(reasoningId));
1173
+ agent.emit(reasoningEndEvent(reasoningId));
1174
+ agent.emit(textChunkEvent(textId, "All done."));
1175
+ agent.emit(runFinishedEvent());
1176
+ agent.complete();
1177
+
1178
+ // Panel should remain open (not auto-collapse)
1179
+ await waitFor(() => {
1180
+ const b = screen.getByText(/Thought for/).closest("button");
1181
+ expect(b?.getAttribute("aria-expanded")).toBe("true");
1182
+ });
1183
+ });
1184
+
1063
1185
  it("should expand and collapse reasoning content on click", async () => {
1064
1186
  const agent = new MockStepwiseAgent();
1065
1187
  renderWithCopilotKit({ agent });
@@ -1094,12 +1216,16 @@ describe("CopilotChat E2E - Chat Basics and Streaming Patterns", () => {
1094
1216
  expect(button?.getAttribute("aria-expanded")).toBe("false");
1095
1217
  });
1096
1218
 
1097
- // Click to expand
1219
+ // Click to expand — wrap in act() so React 18 flushes the state
1220
+ // update synchronously instead of deferring it through the scheduler,
1221
+ // which can race with waitFor polling on slow CI runners.
1098
1222
  const header = screen.getByText(/Thought for/);
1099
1223
  const button = header.closest("button");
1100
- if (button) {
1101
- fireEvent.click(button);
1102
- }
1224
+ act(() => {
1225
+ if (button) {
1226
+ fireEvent.click(button);
1227
+ }
1228
+ });
1103
1229
 
1104
1230
  // Should now be expanded
1105
1231
  await waitFor(() => {
@@ -669,7 +669,7 @@ describe("CopilotChatAssistantMessage", () => {
669
669
 
670
670
  await waitFor(() => {
671
671
  expect(consoleSpy).toHaveBeenCalledWith(
672
- "Failed to copy message:",
672
+ "Failed to copy to clipboard:",
673
673
  expect.any(Error),
674
674
  );
675
675
  });