@copilotkit/react-core 1.55.3 → 1.56.1

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 (53) hide show
  1. package/dist/{copilotkit-dwDWYpya.d.cts → copilotkit-BtP7w7cT.d.cts} +56 -10
  2. package/dist/copilotkit-BtP7w7cT.d.cts.map +1 -0
  3. package/dist/{copilotkit-BuhSUZHb.d.mts → copilotkit-CCbxm6JM.d.mts} +56 -10
  4. package/dist/copilotkit-CCbxm6JM.d.mts.map +1 -0
  5. package/dist/{copilotkit-Dgdpbqjt.cjs → copilotkit-CSJw5BG8.cjs} +129 -58
  6. package/dist/copilotkit-CSJw5BG8.cjs.map +1 -0
  7. package/dist/{copilotkit-Cd-NrDyp.mjs → copilotkit-Cj2ZIxVr.mjs} +125 -60
  8. package/dist/copilotkit-Cj2ZIxVr.mjs.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 +55 -23
  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 +124 -59
  20. package/dist/v2/index.umd.js.map +1 -1
  21. package/package.json +6 -6
  22. package/src/components/CopilotListeners.tsx +15 -4
  23. package/src/components/__tests__/CopilotListeners.test.tsx +38 -0
  24. package/src/components/copilot-provider/__tests__/error-visibility-prod.test.tsx +70 -0
  25. package/src/components/copilot-provider/copilot-messages.tsx +39 -24
  26. package/src/components/copilot-provider/copilotkit-props.tsx +26 -6
  27. package/src/components/copilot-provider/copilotkit.tsx +4 -1
  28. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +22 -19
  29. package/src/v2/components/chat/CopilotChatInput.tsx +21 -2
  30. package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +17 -4
  31. package/src/v2/components/chat/CopilotChatUserMessage.tsx +13 -10
  32. package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +131 -5
  33. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +1 -1
  34. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
  35. package/src/v2/components/chat/__tests__/CopilotChatCopyButton.clipboard.test.tsx +241 -0
  36. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
  37. package/src/v2/components/ui/button.tsx +12 -11
  38. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +10 -10
  39. package/src/v2/hooks/__tests__/use-capabilities.test.tsx +76 -0
  40. package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
  41. package/src/v2/hooks/index.ts +1 -0
  42. package/src/v2/hooks/use-agent.tsx +23 -4
  43. package/src/v2/hooks/use-capabilities.tsx +25 -0
  44. package/src/v2/hooks/use-render-custom-messages.tsx +1 -1
  45. package/src/v2/hooks/use-render-tool-call.tsx +3 -0
  46. package/src/v2/hooks/use-render-tool.tsx +3 -0
  47. package/src/v2/providers/CopilotKitProvider.tsx +15 -2
  48. package/src/v2/types/defineToolCallRenderer.ts +3 -0
  49. package/src/v2/types/react-tool-call-renderer.ts +3 -0
  50. package/dist/copilotkit-BuhSUZHb.d.mts.map +0 -1
  51. package/dist/copilotkit-Cd-NrDyp.mjs.map +0 -1
  52. package/dist/copilotkit-Dgdpbqjt.cjs.map +0 -1
  53. package/dist/copilotkit-dwDWYpya.d.cts.map +0 -1
@@ -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
  });
@@ -0,0 +1,72 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { render, screen, fireEvent } from "@testing-library/react";
4
+ import { AssistantMessage } from "@ag-ui/core";
5
+ import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage";
6
+ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
7
+ import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
8
+
9
+ const TEST_THREAD_ID = "test-thread";
10
+
11
+ const renderWithProvider = (component: React.ReactElement) => {
12
+ return render(
13
+ <CopilotKitProvider>
14
+ <CopilotChatConfigurationProvider threadId={TEST_THREAD_ID}>
15
+ {component}
16
+ </CopilotChatConfigurationProvider>
17
+ </CopilotKitProvider>,
18
+ );
19
+ };
20
+
21
+ describe("CopilotChatAssistantMessage thumbs callbacks (#3457)", () => {
22
+ const message: AssistantMessage = {
23
+ id: "msg-1",
24
+ role: "assistant",
25
+ content: "Hello from the assistant",
26
+ };
27
+
28
+ it("onThumbsUp receives AssistantMessage, not SyntheticEvent", () => {
29
+ const onThumbsUp = vi.fn();
30
+
31
+ renderWithProvider(
32
+ <CopilotChatAssistantMessage message={message} onThumbsUp={onThumbsUp} />,
33
+ );
34
+
35
+ const thumbsUpButton = screen.getByRole("button", {
36
+ name: /good response/i,
37
+ });
38
+ fireEvent.click(thumbsUpButton);
39
+
40
+ expect(onThumbsUp).toHaveBeenCalledTimes(1);
41
+ const arg = onThumbsUp.mock.calls[0][0];
42
+ // Should receive AssistantMessage
43
+ expect(arg).toHaveProperty("id", "msg-1");
44
+ expect(arg).toHaveProperty("role", "assistant");
45
+ expect(arg).toHaveProperty("content", "Hello from the assistant");
46
+ // Should NOT receive a SyntheticEvent (which has nativeEvent, target, etc.)
47
+ expect(arg).not.toHaveProperty("nativeEvent");
48
+ });
49
+
50
+ it("onThumbsDown receives AssistantMessage, not SyntheticEvent", () => {
51
+ const onThumbsDown = vi.fn();
52
+
53
+ renderWithProvider(
54
+ <CopilotChatAssistantMessage
55
+ message={message}
56
+ onThumbsDown={onThumbsDown}
57
+ />,
58
+ );
59
+
60
+ const thumbsDownButton = screen.getByRole("button", {
61
+ name: /bad response/i,
62
+ });
63
+ fireEvent.click(thumbsDownButton);
64
+
65
+ expect(onThumbsDown).toHaveBeenCalledTimes(1);
66
+ const arg = onThumbsDown.mock.calls[0][0];
67
+ expect(arg).toHaveProperty("id", "msg-1");
68
+ expect(arg).toHaveProperty("role", "assistant");
69
+ expect(arg).toHaveProperty("content", "Hello from the assistant");
70
+ expect(arg).not.toHaveProperty("nativeEvent");
71
+ });
72
+ });
@@ -0,0 +1,241 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage";
5
+ import { CopilotChatUserMessage } from "../CopilotChatUserMessage";
6
+ import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
7
+ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
8
+ import { AssistantMessage, UserMessage } from "@ag-ui/core";
9
+
10
+ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
11
+ <CopilotKitProvider>
12
+ <CopilotChatConfigurationProvider threadId="test-thread">
13
+ {children}
14
+ </CopilotChatConfigurationProvider>
15
+ </CopilotKitProvider>
16
+ );
17
+
18
+ const createAssistantMessage = (content: string): AssistantMessage => ({
19
+ id: "msg-assistant-1",
20
+ role: "assistant",
21
+ content,
22
+ });
23
+
24
+ const createUserMessage = (content: string): UserMessage => ({
25
+ id: "msg-user-1",
26
+ role: "user",
27
+ content,
28
+ });
29
+
30
+ describe("CopyButton clipboard behavior", () => {
31
+ let originalClipboard: Clipboard;
32
+
33
+ beforeEach(() => {
34
+ originalClipboard = navigator.clipboard;
35
+ });
36
+
37
+ afterEach(() => {
38
+ Object.defineProperty(navigator, "clipboard", {
39
+ value: originalClipboard,
40
+ writable: true,
41
+ configurable: true,
42
+ });
43
+ });
44
+
45
+ describe("AssistantMessage CopyButton", () => {
46
+ it("shows copied state only after successful clipboard write", async () => {
47
+ const writeTextMock = vi.fn().mockResolvedValue(undefined);
48
+ Object.defineProperty(navigator, "clipboard", {
49
+ value: { writeText: writeTextMock },
50
+ writable: true,
51
+ configurable: true,
52
+ });
53
+
54
+ const message = createAssistantMessage("Hello assistant");
55
+ render(
56
+ <TestWrapper>
57
+ <CopilotChatAssistantMessage message={message} />
58
+ </TestWrapper>,
59
+ );
60
+
61
+ const copyButton = screen.getByTestId("copilot-copy-button");
62
+ fireEvent.click(copyButton);
63
+
64
+ await waitFor(() => {
65
+ expect(writeTextMock).toHaveBeenCalledWith("Hello assistant");
66
+ });
67
+
68
+ // After successful write, should show check icon
69
+ await waitFor(() => {
70
+ const checkIcon = copyButton.querySelector(".lucide-check");
71
+ expect(checkIcon).not.toBeNull();
72
+ });
73
+ });
74
+
75
+ it("does NOT show copied state when clipboard API is unavailable", async () => {
76
+ Object.defineProperty(navigator, "clipboard", {
77
+ value: undefined,
78
+ writable: true,
79
+ configurable: true,
80
+ });
81
+
82
+ const message = createAssistantMessage("Hello assistant");
83
+ render(
84
+ <TestWrapper>
85
+ <CopilotChatAssistantMessage message={message} />
86
+ </TestWrapper>,
87
+ );
88
+
89
+ const copyButton = screen.getByTestId("copilot-copy-button");
90
+ fireEvent.click(copyButton);
91
+
92
+ // Wait a tick for any async handlers
93
+ await new Promise((r) => setTimeout(r, 50));
94
+
95
+ // Should still show copy icon (not check icon)
96
+ const checkIcon = copyButton.querySelector(".lucide-check");
97
+ expect(checkIcon).toBeNull();
98
+ const copyIcon = copyButton.querySelector(".lucide-copy");
99
+ expect(copyIcon).not.toBeNull();
100
+ });
101
+
102
+ it("logs error when clipboard write rejects", async () => {
103
+ const writeTextMock = vi
104
+ .fn()
105
+ .mockRejectedValue(new Error("Permission denied"));
106
+ Object.defineProperty(navigator, "clipboard", {
107
+ value: { writeText: writeTextMock },
108
+ writable: true,
109
+ configurable: true,
110
+ });
111
+
112
+ const consoleSpy = vi
113
+ .spyOn(console, "error")
114
+ .mockImplementation(() => {});
115
+
116
+ const message = createAssistantMessage("Hello assistant");
117
+ render(
118
+ <TestWrapper>
119
+ <CopilotChatAssistantMessage message={message} />
120
+ </TestWrapper>,
121
+ );
122
+
123
+ const copyButton = screen.getByTestId("copilot-copy-button");
124
+ fireEvent.click(copyButton);
125
+
126
+ await waitFor(() => {
127
+ expect(writeTextMock).toHaveBeenCalled();
128
+ });
129
+
130
+ await waitFor(() => {
131
+ expect(consoleSpy).toHaveBeenCalledWith(
132
+ "Failed to copy to clipboard:",
133
+ expect.any(Error),
134
+ );
135
+ });
136
+
137
+ // Should NOT show copied state when write failed
138
+ const checkIcon = copyButton.querySelector(".lucide-check");
139
+ expect(checkIcon).toBeNull();
140
+
141
+ consoleSpy.mockRestore();
142
+ });
143
+ });
144
+
145
+ describe("UserMessage CopyButton", () => {
146
+ it("shows copied state only after successful clipboard write", async () => {
147
+ const writeTextMock = vi.fn().mockResolvedValue(undefined);
148
+ Object.defineProperty(navigator, "clipboard", {
149
+ value: { writeText: writeTextMock },
150
+ writable: true,
151
+ configurable: true,
152
+ });
153
+
154
+ const message = createUserMessage("Hello user");
155
+ render(
156
+ <TestWrapper>
157
+ <CopilotChatUserMessage message={message} />
158
+ </TestWrapper>,
159
+ );
160
+
161
+ const copyButton = screen.getByTestId("copilot-user-copy-button");
162
+ fireEvent.click(copyButton);
163
+
164
+ await waitFor(() => {
165
+ expect(writeTextMock).toHaveBeenCalledWith("Hello user");
166
+ });
167
+
168
+ await waitFor(() => {
169
+ const checkIcon = copyButton.querySelector(".lucide-check");
170
+ expect(checkIcon).not.toBeNull();
171
+ });
172
+ });
173
+
174
+ it("does NOT show copied state when clipboard API is unavailable", async () => {
175
+ Object.defineProperty(navigator, "clipboard", {
176
+ value: undefined,
177
+ writable: true,
178
+ configurable: true,
179
+ });
180
+
181
+ const message = createUserMessage("Hello user");
182
+ render(
183
+ <TestWrapper>
184
+ <CopilotChatUserMessage message={message} />
185
+ </TestWrapper>,
186
+ );
187
+
188
+ const copyButton = screen.getByTestId("copilot-user-copy-button");
189
+ fireEvent.click(copyButton);
190
+
191
+ await new Promise((r) => setTimeout(r, 50));
192
+
193
+ const checkIcon = copyButton.querySelector(".lucide-check");
194
+ expect(checkIcon).toBeNull();
195
+ const copyIcon = copyButton.querySelector(".lucide-copy");
196
+ expect(copyIcon).not.toBeNull();
197
+ });
198
+
199
+ it("logs error when clipboard write rejects", async () => {
200
+ const writeTextMock = vi
201
+ .fn()
202
+ .mockRejectedValue(new Error("Permission denied"));
203
+ Object.defineProperty(navigator, "clipboard", {
204
+ value: { writeText: writeTextMock },
205
+ writable: true,
206
+ configurable: true,
207
+ });
208
+
209
+ const consoleSpy = vi
210
+ .spyOn(console, "error")
211
+ .mockImplementation(() => {});
212
+
213
+ const message = createUserMessage("Hello user");
214
+ render(
215
+ <TestWrapper>
216
+ <CopilotChatUserMessage message={message} />
217
+ </TestWrapper>,
218
+ );
219
+
220
+ const copyButton = screen.getByTestId("copilot-user-copy-button");
221
+ fireEvent.click(copyButton);
222
+
223
+ await waitFor(() => {
224
+ expect(writeTextMock).toHaveBeenCalled();
225
+ });
226
+
227
+ await waitFor(() => {
228
+ expect(consoleSpy).toHaveBeenCalledWith(
229
+ "Failed to copy to clipboard:",
230
+ expect.any(Error),
231
+ );
232
+ });
233
+
234
+ // Should NOT show copied state when write failed
235
+ const checkIcon = copyButton.querySelector(".lucide-check");
236
+ expect(checkIcon).toBeNull();
237
+
238
+ consoleSpy.mockRestore();
239
+ });
240
+ });
241
+ });
@@ -985,6 +985,44 @@ describe("CopilotChatInput", () => {
985
985
  expect((input as HTMLTextAreaElement).value).toBe("test message");
986
986
  expect(mockOnSubmitMessage).toHaveBeenCalledWith("test message");
987
987
  });
988
+
989
+ it("calls onChange with empty string after submission in controlled mode", () => {
990
+ const mockOnChange = vi.fn();
991
+ const mockOnSubmitMessage = vi.fn();
992
+
993
+ const { container } = renderWithProvider(
994
+ <CopilotChatInput
995
+ value="test message"
996
+ onChange={mockOnChange}
997
+ onSubmitMessage={mockOnSubmitMessage}
998
+ />,
999
+ );
1000
+
1001
+ const sendButton = getSendButton(container);
1002
+ fireEvent.click(sendButton!);
1003
+
1004
+ expect(mockOnSubmitMessage).toHaveBeenCalledWith("test message");
1005
+ expect(mockOnChange).toHaveBeenCalledWith("");
1006
+ });
1007
+
1008
+ it("calls onChange with empty string after Enter submission in controlled mode", () => {
1009
+ const mockOnChange = vi.fn();
1010
+ const mockOnSubmitMessage = vi.fn();
1011
+
1012
+ renderWithProvider(
1013
+ <CopilotChatInput
1014
+ value="hello world"
1015
+ onChange={mockOnChange}
1016
+ onSubmitMessage={mockOnSubmitMessage}
1017
+ />,
1018
+ );
1019
+
1020
+ const input = screen.getByRole("textbox");
1021
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
1022
+
1023
+ expect(mockOnSubmitMessage).toHaveBeenCalledWith("hello world");
1024
+ expect(mockOnChange).toHaveBeenCalledWith("");
1025
+ });
988
1026
  });
989
1027
 
990
1028
  describe("Container dimension cache", () => {
@@ -99,25 +99,26 @@ const buttonVariants = cva(
99
99
  },
100
100
  );
101
101
 
102
- function Button({
103
- className,
104
- variant,
105
- size,
106
- asChild = false,
107
- ...props
108
- }: React.ComponentProps<"button"> &
109
- VariantProps<typeof buttonVariants> & {
110
- asChild?: boolean;
111
- }) {
102
+ const Button = React.forwardRef<
103
+ HTMLButtonElement,
104
+ React.ComponentProps<"button"> &
105
+ VariantProps<typeof buttonVariants> & {
106
+ asChild?: boolean;
107
+ }
108
+ >(function Button(
109
+ { className, variant, size, asChild = false, ...props },
110
+ ref,
111
+ ) {
112
112
  const Comp = asChild ? Slot : "button";
113
113
 
114
114
  return (
115
115
  <Comp
116
+ ref={ref}
116
117
  data-slot="button"
117
118
  className={cn(buttonVariants({ variant, size, className }))}
118
119
  {...props}
119
120
  />
120
121
  );
121
- }
122
+ });
122
123
 
123
124
  export { Button, buttonVariants };
@@ -313,7 +313,7 @@ describe("useAgent throttleMs", () => {
313
313
  expect(screen.getByTestId("count").textContent).toBe("3");
314
314
  });
315
315
 
316
- it("with throttleMs, onStateChanged still fires immediately", () => {
316
+ it("with throttleMs, onStateChanged still fires immediately", async () => {
317
317
  const TestComponent = createTestComponent({
318
318
  updates: [
319
319
  UseAgentUpdate.OnMessagesChanged,
@@ -330,8 +330,8 @@ describe("useAgent throttleMs", () => {
330
330
  notifyMessagesChanged(mockAgent);
331
331
  });
332
332
 
333
- // Fire onStateChanged 10ms later — should render immediately, not throttled
334
- act(() => {
333
+ // Fire onStateChanged 10ms later — fires via microtask batch (not synchronously)
334
+ await act(async () => {
335
335
  vi.advanceTimersByTime(10);
336
336
  mockAgent.state = { count: 42 };
337
337
  notifyStateChanged(mockAgent);
@@ -372,7 +372,7 @@ describe("useAgent throttleMs", () => {
372
372
  expect(renderCount.current).toBe(countBeforeUnmount);
373
373
  });
374
374
 
375
- it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", () => {
375
+ it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", async () => {
376
376
  const TestComponent = createTestComponent({
377
377
  updates: [UseAgentUpdate.OnStateChanged],
378
378
  throttleMs: 100,
@@ -380,8 +380,8 @@ describe("useAgent throttleMs", () => {
380
380
 
381
381
  render(<TestComponent />);
382
382
 
383
- // Only onStateChanged is subscribed — should fire immediately
384
- act(() => {
383
+ // Only onStateChanged is subscribed — fires via microtask batch
384
+ await act(async () => {
385
385
  mockAgent.state = { value: "test" };
386
386
  notifyStateChanged(mockAgent);
387
387
  });
@@ -649,7 +649,7 @@ describe("useAgent throttleMs", () => {
649
649
  expect(renderCount.current).toBe(rendersAfterMount + 1);
650
650
  });
651
651
 
652
- it("with throttleMs, onRunInitialized still fires immediately during throttle window", () => {
652
+ it("with throttleMs, onRunInitialized still fires immediately during throttle window", async () => {
653
653
  const renderCount = { current: 0 };
654
654
  const TestComponent = createTestComponent({
655
655
  updates: [
@@ -670,13 +670,13 @@ describe("useAgent throttleMs", () => {
670
670
  });
671
671
  expect(renderCount.current).toBe(rendersAfterMount + 1);
672
672
 
673
- // Fire onRunInitialized 10ms later — should render immediately
674
- act(() => {
673
+ // Fire onRunInitialized 10ms later — fires via microtask batch
674
+ await act(async () => {
675
675
  vi.advanceTimersByTime(10);
676
676
  notifyRunInitialized(mockAgent);
677
677
  });
678
678
 
679
- // Run status notification is NOT throttled — renders immediately
679
+ // Run status notification fires via microtask batch
680
680
  expect(renderCount.current).toBe(rendersAfterMount + 2);
681
681
  });
682
682