@copilotkit/react-core 1.54.1 → 1.55.0-next.7

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 (183) hide show
  1. package/CHANGELOG.md +117 -116
  2. package/dist/copilotkit-B3Mb1yVE.cjs +7975 -0
  3. package/dist/copilotkit-B3Mb1yVE.cjs.map +1 -0
  4. package/dist/copilotkit-DBzgOMby.d.cts +2182 -0
  5. package/dist/copilotkit-DBzgOMby.d.cts.map +1 -0
  6. package/dist/copilotkit-DNYSFuz5.mjs +7562 -0
  7. package/dist/copilotkit-DNYSFuz5.mjs.map +1 -0
  8. package/dist/copilotkit-Dy5w3qEV.d.mts +2182 -0
  9. package/dist/copilotkit-Dy5w3qEV.d.mts.map +1 -0
  10. package/dist/index.cjs +27 -28
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +3 -3
  13. package/dist/index.d.cts.map +1 -1
  14. package/dist/index.d.mts +3 -3
  15. package/dist/index.d.mts.map +1 -1
  16. package/dist/index.mjs +4 -5
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/index.umd.js +1941 -35
  19. package/dist/index.umd.js.map +1 -1
  20. package/dist/v2/index.cjs +77 -7
  21. package/dist/v2/index.css +1 -2
  22. package/dist/v2/index.d.cts +6 -4
  23. package/dist/v2/index.d.mts +6 -4
  24. package/dist/v2/index.mjs +7 -4
  25. package/dist/v2/index.umd.js +5725 -24
  26. package/dist/v2/index.umd.js.map +1 -1
  27. package/package.json +37 -9
  28. package/scripts/scope-preflight.mjs +101 -0
  29. package/src/components/CopilotListeners.tsx +2 -6
  30. package/src/components/copilot-provider/copilot-messages.tsx +1 -1
  31. package/src/components/copilot-provider/copilotkit-props.tsx +1 -1
  32. package/src/components/copilot-provider/copilotkit.tsx +4 -4
  33. package/src/context/copilot-messages-context.tsx +1 -1
  34. package/src/hooks/__tests__/use-coagent-config.test.ts +2 -2
  35. package/src/hooks/__tests__/use-coagent-state-render.e2e.test.tsx +2 -2
  36. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +3 -7
  37. package/src/hooks/__tests__/use-frontend-tool-available.test.tsx +1 -1
  38. package/src/hooks/__tests__/use-frontend-tool-remount.e2e.test.tsx +4 -4
  39. package/src/hooks/use-agent-nodename.ts +1 -1
  40. package/src/hooks/use-coagent-state-render-bridge.tsx +1 -4
  41. package/src/hooks/use-coagent.ts +1 -1
  42. package/src/hooks/use-configure-chat-suggestions.tsx +2 -2
  43. package/src/hooks/use-copilot-chat-suggestions.tsx +2 -2
  44. package/src/hooks/use-copilot-chat_internal.ts +2 -2
  45. package/src/hooks/use-copilot-readable.ts +1 -1
  46. package/src/hooks/use-frontend-tool.ts +2 -2
  47. package/src/hooks/use-human-in-the-loop.ts +2 -2
  48. package/src/hooks/use-langgraph-interrupt.ts +2 -5
  49. package/src/hooks/use-lazy-tool-renderer.tsx +1 -1
  50. package/src/hooks/use-render-tool-call.ts +1 -1
  51. package/src/lib/copilot-task.ts +1 -1
  52. package/src/setupTests.ts +18 -14
  53. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +176 -0
  54. package/src/v2/__tests__/globalSetup.ts +14 -0
  55. package/src/v2/__tests__/setup.ts +93 -0
  56. package/src/v2/__tests__/utils/test-helpers.tsx +470 -0
  57. package/src/v2/a2ui/A2UIMessageRenderer.tsx +206 -0
  58. package/src/v2/components/CopilotKitInspector.tsx +50 -0
  59. package/src/v2/components/MCPAppsActivityRenderer.tsx +785 -0
  60. package/src/v2/components/WildcardToolCallRender.tsx +86 -0
  61. package/src/v2/components/__tests__/license-warning-banner.test.tsx +46 -0
  62. package/src/v2/components/chat/CopilotChat.tsx +431 -0
  63. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +375 -0
  64. package/src/v2/components/chat/CopilotChatAudioRecorder.tsx +350 -0
  65. package/src/v2/components/chat/CopilotChatInput.tsx +1302 -0
  66. package/src/v2/components/chat/CopilotChatMessageView.tsx +556 -0
  67. package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +252 -0
  68. package/src/v2/components/chat/CopilotChatSuggestionPill.tsx +59 -0
  69. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +133 -0
  70. package/src/v2/components/chat/CopilotChatToggleButton.tsx +171 -0
  71. package/src/v2/components/chat/CopilotChatToolCallsView.tsx +40 -0
  72. package/src/v2/components/chat/CopilotChatUserMessage.tsx +388 -0
  73. package/src/v2/components/chat/CopilotChatView.tsx +598 -0
  74. package/src/v2/components/chat/CopilotModalHeader.tsx +129 -0
  75. package/src/v2/components/chat/CopilotPopup.tsx +81 -0
  76. package/src/v2/components/chat/CopilotPopupView.tsx +317 -0
  77. package/src/v2/components/chat/CopilotSidebar.tsx +76 -0
  78. package/src/v2/components/chat/CopilotSidebarView.tsx +255 -0
  79. package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +1113 -0
  80. package/src/v2/components/chat/__tests__/CopilotChat.onError.test.tsx +73 -0
  81. package/src/v2/components/chat/__tests__/CopilotChat.slots.e2e.test.tsx +432 -0
  82. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +150 -0
  83. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.slots.e2e.test.tsx +624 -0
  84. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +702 -0
  85. package/src/v2/components/chat/__tests__/CopilotChatCssClasses.test.tsx +107 -0
  86. package/src/v2/components/chat/__tests__/CopilotChatInput.slots.e2e.test.tsx +929 -0
  87. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +986 -0
  88. package/src/v2/components/chat/__tests__/CopilotChatMessageView.slots.e2e.test.tsx +1004 -0
  89. package/src/v2/components/chat/__tests__/CopilotChatMessageView.test.tsx +169 -0
  90. package/src/v2/components/chat/__tests__/CopilotChatSuggestionView.slots.e2e.test.tsx +530 -0
  91. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +782 -0
  92. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +2413 -0
  93. package/src/v2/components/chat/__tests__/CopilotChatUserMessage.slots.e2e.test.tsx +621 -0
  94. package/src/v2/components/chat/__tests__/CopilotChatView.onClick.e2e.test.tsx +853 -0
  95. package/src/v2/components/chat/__tests__/CopilotChatView.slots.e2e.test.tsx +1050 -0
  96. package/src/v2/components/chat/__tests__/CopilotModalHeader.slots.e2e.test.tsx +484 -0
  97. package/src/v2/components/chat/__tests__/CopilotPopupView.slots.e2e.test.tsx +612 -0
  98. package/src/v2/components/chat/__tests__/CopilotSidebarView.slots.e2e.test.tsx +502 -0
  99. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +1011 -0
  100. package/src/v2/components/chat/__tests__/setup.ts +1 -0
  101. package/src/v2/components/chat/index.ts +79 -0
  102. package/src/v2/components/index.ts +7 -0
  103. package/src/v2/components/license-warning-banner.tsx +198 -0
  104. package/src/v2/components/ui/button.tsx +123 -0
  105. package/src/v2/components/ui/dropdown-menu.tsx +258 -0
  106. package/src/v2/components/ui/tooltip.tsx +60 -0
  107. package/src/v2/hooks/__tests__/standard-schema-types.test.tsx +152 -0
  108. package/src/v2/hooks/__tests__/standard-schema.test.tsx +282 -0
  109. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +132 -0
  110. package/src/v2/hooks/__tests__/use-agent-context.test.tsx +401 -0
  111. package/src/v2/hooks/__tests__/use-agent-error-state.test.tsx +44 -0
  112. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +205 -0
  113. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +148 -0
  114. package/src/v2/hooks/__tests__/use-component.test.tsx +123 -0
  115. package/src/v2/hooks/__tests__/use-configure-suggestions.e2e.test.tsx +696 -0
  116. package/src/v2/hooks/__tests__/use-default-render-tool.test.tsx +153 -0
  117. package/src/v2/hooks/__tests__/use-frontend-tool-available.test.tsx +167 -0
  118. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +2129 -0
  119. package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx +1261 -0
  120. package/src/v2/hooks/__tests__/use-interrupt.test.tsx +397 -0
  121. package/src/v2/hooks/__tests__/use-katex-styles.test.tsx +56 -0
  122. package/src/v2/hooks/__tests__/use-keyboard-height.test.tsx +192 -0
  123. package/src/v2/hooks/__tests__/use-render-tool.test.tsx +259 -0
  124. package/src/v2/hooks/__tests__/use-suggestions.e2e.test.tsx +524 -0
  125. package/src/v2/hooks/__tests__/use-threads.test.tsx +433 -0
  126. package/src/v2/hooks/__tests__/zod-regression.test.tsx +311 -0
  127. package/src/v2/hooks/index.ts +18 -0
  128. package/src/v2/hooks/use-agent-context.tsx +45 -0
  129. package/src/v2/hooks/use-agent.tsx +155 -0
  130. package/src/v2/hooks/use-component.tsx +89 -0
  131. package/src/v2/hooks/use-configure-suggestions.tsx +187 -0
  132. package/src/v2/hooks/use-default-render-tool.tsx +254 -0
  133. package/src/v2/hooks/use-frontend-tool.tsx +43 -0
  134. package/src/v2/hooks/use-human-in-the-loop.tsx +81 -0
  135. package/src/v2/hooks/use-interrupt.tsx +305 -0
  136. package/src/v2/hooks/use-keyboard-height.tsx +67 -0
  137. package/src/v2/hooks/use-render-activity-message.tsx +73 -0
  138. package/src/v2/hooks/use-render-custom-messages.tsx +93 -0
  139. package/src/v2/hooks/use-render-tool-call.tsx +175 -0
  140. package/src/v2/hooks/use-render-tool.tsx +181 -0
  141. package/src/v2/hooks/use-suggestions.tsx +91 -0
  142. package/src/v2/hooks/use-threads.tsx +256 -0
  143. package/src/v2/hooks/useKatexStyles.ts +27 -0
  144. package/src/v2/index.css +1 -1
  145. package/src/v2/index.ts +18 -2
  146. package/src/v2/lib/__tests__/completePartialMarkdown.test.ts +495 -0
  147. package/src/v2/lib/__tests__/renderSlot.test.tsx +588 -0
  148. package/src/v2/lib/react-core.ts +156 -0
  149. package/src/v2/lib/slots.tsx +143 -0
  150. package/src/v2/lib/transcription-client.ts +184 -0
  151. package/src/v2/lib/utils.ts +8 -0
  152. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +162 -0
  153. package/src/v2/providers/CopilotKitProvider.tsx +600 -0
  154. package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +546 -0
  155. package/src/v2/providers/__tests__/CopilotKitProvider.license.test.tsx +101 -0
  156. package/src/v2/providers/__tests__/CopilotKitProvider.onError.test.tsx +69 -0
  157. package/src/v2/providers/__tests__/CopilotKitProvider.renderCustomMessages.e2e.test.tsx +881 -0
  158. package/src/v2/providers/__tests__/CopilotKitProvider.stability.test.tsx +740 -0
  159. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +642 -0
  160. package/src/v2/providers/__tests__/CopilotKitProvider.wildcard.test.tsx +294 -0
  161. package/src/v2/providers/index.ts +14 -0
  162. package/src/v2/styles/globals.css +230 -0
  163. package/src/v2/types/__tests__/defineToolCallRenderer.test.tsx +525 -0
  164. package/src/v2/types/defineToolCallRenderer.ts +65 -0
  165. package/src/v2/types/frontend-tool.ts +8 -0
  166. package/src/v2/types/human-in-the-loop.ts +33 -0
  167. package/src/v2/types/index.ts +7 -0
  168. package/src/v2/types/interrupt.ts +15 -0
  169. package/src/v2/types/react-activity-message-renderer.ts +27 -0
  170. package/src/v2/types/react-custom-message-renderer.ts +17 -0
  171. package/src/v2/types/react-tool-call-renderer.ts +32 -0
  172. package/tsdown.config.ts +34 -10
  173. package/vitest.config.mjs +4 -3
  174. package/LICENSE +0 -21
  175. package/dist/copilotkit-BRPQ2sqS.d.cts +0 -670
  176. package/dist/copilotkit-BRPQ2sqS.d.cts.map +0 -1
  177. package/dist/copilotkit-C94ayZbs.cjs +0 -2161
  178. package/dist/copilotkit-C94ayZbs.cjs.map +0 -1
  179. package/dist/copilotkit-CwZMFmSK.d.mts +0 -670
  180. package/dist/copilotkit-CwZMFmSK.d.mts.map +0 -1
  181. package/dist/copilotkit-Yh_Ld_FX.mjs +0 -2031
  182. package/dist/copilotkit-Yh_Ld_FX.mjs.map +0 -1
  183. package/dist/v2/index.css.map +0 -1
@@ -0,0 +1,2129 @@
1
+ import React, { useEffect, useState, useReducer } from "react";
2
+ import { screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { z } from "zod";
4
+ import { useFrontendTool } from "../use-frontend-tool";
5
+ import { ReactFrontendTool } from "../../types";
6
+ import { CopilotChat } from "../../components/chat/CopilotChat";
7
+ import CopilotChatToolCallsView from "../../components/chat/CopilotChatToolCallsView";
8
+ import { AssistantMessage, Message } from "@ag-ui/core";
9
+ import { ToolCallStatus } from "@copilotkit/core";
10
+ import {
11
+ AbstractAgent,
12
+ EventType,
13
+ type AgentSubscriber,
14
+ type BaseEvent,
15
+ type RunAgentInput,
16
+ type RunAgentParameters,
17
+ } from "@ag-ui/client";
18
+ import { Observable } from "rxjs";
19
+ import {
20
+ MockStepwiseAgent,
21
+ renderWithCopilotKit,
22
+ runStartedEvent,
23
+ runFinishedEvent,
24
+ toolCallChunkEvent,
25
+ toolCallResultEvent,
26
+ textChunkEvent,
27
+ testId,
28
+ } from "../../__tests__/utils/test-helpers";
29
+
30
+ describe("useFrontendTool E2E - Dynamic Registration", () => {
31
+ describe("Minimal dynamic registration without chat run", () => {
32
+ it("registers tool and renders tool call via ToolCallsView", async () => {
33
+ // No agent run; we render ToolCallsView directly
34
+ const DynamicToolComponent: React.FC = () => {
35
+ const tool: ReactFrontendTool<{ message: string }> = {
36
+ name: "dynamicTool",
37
+ parameters: z.object({ message: z.string() }),
38
+ render: ({ name, args }) => (
39
+ <div data-testid="dynamic-tool-render">
40
+ {name}: {args.message}
41
+ </div>
42
+ ),
43
+ };
44
+ useFrontendTool(tool);
45
+ return null;
46
+ };
47
+
48
+ const toolCallId = testId("tc_dyn");
49
+ const assistantMessage: AssistantMessage = {
50
+ id: testId("a"),
51
+ role: "assistant",
52
+ content: "",
53
+ toolCalls: [
54
+ {
55
+ id: toolCallId,
56
+ type: "function",
57
+ function: {
58
+ name: "dynamicTool",
59
+ arguments: JSON.stringify({ message: "hello" }),
60
+ },
61
+ } as any,
62
+ ],
63
+ } as any;
64
+ const messages: Message[] = [];
65
+
66
+ const ui = renderWithCopilotKit({
67
+ children: (
68
+ <>
69
+ <DynamicToolComponent />
70
+ <CopilotChatToolCallsView
71
+ message={assistantMessage}
72
+ messages={messages}
73
+ />
74
+ </>
75
+ ),
76
+ });
77
+
78
+ await waitFor(() => {
79
+ const el = screen.getByTestId("dynamic-tool-render");
80
+ expect(el).toBeDefined();
81
+ expect(el.textContent).toContain("dynamicTool");
82
+ expect(el.textContent).toContain("hello");
83
+ });
84
+ // Explicitly unmount to avoid any lingering handles
85
+ ui.unmount();
86
+ });
87
+ });
88
+ describe("Register at runtime", () => {
89
+ it("should register tool dynamically after provider is mounted", async () => {
90
+ const agent = new MockStepwiseAgent();
91
+
92
+ // Inner component that uses the hook
93
+ const ToolUser: React.FC = () => {
94
+ const tool: ReactFrontendTool<{ message: string }> = {
95
+ name: "dynamicTool",
96
+ parameters: z.object({ message: z.string() }),
97
+ render: ({ name, args, result }) => (
98
+ <div data-testid="dynamic-tool-render">
99
+ {name}: {args.message} | Result:{" "}
100
+ {result ? JSON.stringify(result) : "pending"}
101
+ </div>
102
+ ),
103
+ handler: async (args) => {
104
+ return { processed: args.message.toUpperCase() };
105
+ },
106
+ };
107
+
108
+ useFrontendTool(tool);
109
+ return null;
110
+ };
111
+
112
+ // Component that registers a tool after mount
113
+ const DynamicToolComponent: React.FC = () => {
114
+ const [isRegistered, setIsRegistered] = useState(false);
115
+
116
+ useEffect(() => {
117
+ // Register immediately after mount
118
+ setIsRegistered(true);
119
+ }, []);
120
+
121
+ return (
122
+ <>
123
+ <div data-testid="dynamic-status">
124
+ {isRegistered ? "Registered" : "Not registered"}
125
+ </div>
126
+ {isRegistered && <ToolUser />}
127
+ </>
128
+ );
129
+ };
130
+
131
+ renderWithCopilotKit({
132
+ agent,
133
+ children: (
134
+ <>
135
+ <DynamicToolComponent />
136
+ <div style={{ height: 400 }}>
137
+ <CopilotChat welcomeScreen={false} />
138
+ </div>
139
+ </>
140
+ ),
141
+ });
142
+
143
+ // Wait for dynamic registration
144
+ await waitFor(() => {
145
+ expect(screen.getByTestId("dynamic-status").textContent).toBe(
146
+ "Registered",
147
+ );
148
+ });
149
+
150
+ // Submit a message that will trigger the dynamically registered tool
151
+ const input = await screen.findByRole("textbox");
152
+ fireEvent.change(input, { target: { value: "Use dynamic tool" } });
153
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
154
+
155
+ // Wait for message to be processed
156
+ await waitFor(() => {
157
+ expect(screen.getByText("Use dynamic tool")).toBeDefined();
158
+ });
159
+
160
+ const messageId = testId("msg");
161
+ const toolCallId = testId("tc");
162
+
163
+ // Emit tool call for the dynamically registered tool
164
+ agent.emit(runStartedEvent());
165
+ agent.emit(
166
+ toolCallChunkEvent({
167
+ toolCallId,
168
+ toolCallName: "dynamicTool",
169
+ parentMessageId: messageId,
170
+ delta: '{"message":"hello world"}',
171
+ }),
172
+ );
173
+
174
+ // The dynamically registered renderer should appear
175
+ await waitFor(() => {
176
+ const toolRender = screen.getByTestId("dynamic-tool-render");
177
+ expect(toolRender).toBeDefined();
178
+ expect(toolRender.textContent).toContain("hello world");
179
+ });
180
+
181
+ // Send result
182
+ agent.emit(
183
+ toolCallResultEvent({
184
+ toolCallId,
185
+ messageId: `${messageId}_result`,
186
+ content: JSON.stringify({ processed: "HELLO WORLD" }),
187
+ }),
188
+ );
189
+
190
+ await waitFor(() => {
191
+ const toolRender = screen.getByTestId("dynamic-tool-render");
192
+ expect(toolRender.textContent).toContain("HELLO WORLD");
193
+ });
194
+
195
+ agent.emit(runFinishedEvent());
196
+ agent.complete();
197
+ });
198
+ });
199
+
200
+ describe("Streaming tool calls with incomplete JSON", () => {
201
+ it("renders tool calls progressively as incomplete JSON chunks arrive", async () => {
202
+ const agent = new MockStepwiseAgent();
203
+
204
+ // Tool that renders the arguments it receives
205
+ const StreamingTool: React.FC = () => {
206
+ const tool: ReactFrontendTool<{
207
+ name: string;
208
+ items: string[];
209
+ count: number;
210
+ }> = {
211
+ name: "streamingTool",
212
+ parameters: z.object({
213
+ name: z.string(),
214
+ items: z.array(z.string()),
215
+ count: z.number(),
216
+ }),
217
+ render: ({ args }) => (
218
+ <div data-testid="streaming-tool-render">
219
+ <div data-testid="tool-name">{args.name || "undefined"}</div>
220
+ <div data-testid="tool-items">
221
+ {args.items ? args.items.join(", ") : "undefined"}
222
+ </div>
223
+ <div data-testid="tool-count">
224
+ {args.count !== undefined ? args.count : "undefined"}
225
+ </div>
226
+ </div>
227
+ ),
228
+ };
229
+
230
+ useFrontendTool(tool);
231
+ return null;
232
+ };
233
+
234
+ renderWithCopilotKit({
235
+ agent,
236
+ children: (
237
+ <>
238
+ <StreamingTool />
239
+ <div style={{ height: 400 }}>
240
+ <CopilotChat welcomeScreen={false} />
241
+ </div>
242
+ </>
243
+ ),
244
+ });
245
+
246
+ // Submit a message to start the agent
247
+ const input = await screen.findByRole("textbox");
248
+ fireEvent.change(input, { target: { value: "Test streaming" } });
249
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
250
+
251
+ // Wait for message to appear
252
+ await waitFor(() => {
253
+ expect(screen.getByText("Test streaming")).toBeDefined();
254
+ });
255
+
256
+ const messageId = testId("msg");
257
+ const toolCallId = testId("tc");
258
+
259
+ // Start the run
260
+ agent.emit(runStartedEvent());
261
+
262
+ // Stream incomplete JSON chunks
263
+ // First chunk: just opening brace and part of first field
264
+ agent.emit(
265
+ toolCallChunkEvent({
266
+ toolCallId,
267
+ toolCallName: "streamingTool",
268
+ parentMessageId: messageId,
269
+ delta: '{"na',
270
+ }),
271
+ );
272
+
273
+ // Check that tool is rendering (even with incomplete JSON)
274
+ await waitFor(() => {
275
+ expect(screen.getByTestId("streaming-tool-render")).toBeDefined();
276
+ });
277
+
278
+ // Second chunk: complete the name field
279
+ agent.emit(
280
+ toolCallChunkEvent({
281
+ toolCallId,
282
+ parentMessageId: messageId,
283
+ delta: 'me":"Test Tool"',
284
+ }),
285
+ );
286
+
287
+ // Check name is now rendered
288
+ await waitFor(() => {
289
+ expect(screen.getByTestId("tool-name").textContent).toBe("Test Tool");
290
+ });
291
+
292
+ // Third chunk: start items array
293
+ agent.emit(
294
+ toolCallChunkEvent({
295
+ toolCallId,
296
+ parentMessageId: messageId,
297
+ delta: ',"items":["item1"',
298
+ }),
299
+ );
300
+
301
+ // Check items array has first item
302
+ await waitFor(() => {
303
+ expect(screen.getByTestId("tool-items").textContent).toContain("item1");
304
+ });
305
+
306
+ // Fourth chunk: add more items and start count
307
+ agent.emit(
308
+ toolCallChunkEvent({
309
+ toolCallId,
310
+ parentMessageId: messageId,
311
+ delta: ',"item2","item3"],"cou',
312
+ }),
313
+ );
314
+
315
+ // Check items array is complete
316
+ await waitFor(() => {
317
+ expect(screen.getByTestId("tool-items").textContent).toBe(
318
+ "item1, item2, item3",
319
+ );
320
+ });
321
+
322
+ // Final chunk: complete the JSON
323
+ agent.emit(
324
+ toolCallChunkEvent({
325
+ toolCallId,
326
+ parentMessageId: messageId,
327
+ delta: 'nt":42}',
328
+ }),
329
+ );
330
+
331
+ // Check count is rendered
332
+ await waitFor(() => {
333
+ expect(screen.getByTestId("tool-count").textContent).toBe("42");
334
+ });
335
+
336
+ agent.emit(runFinishedEvent());
337
+ agent.complete();
338
+ });
339
+ });
340
+
341
+ describe("Tool followUp property behavior", () => {
342
+ it("stops agent execution when followUp is false", async () => {
343
+ const agent = new MockStepwiseAgent();
344
+
345
+ const NoFollowUpTool: React.FC = () => {
346
+ const tool: ReactFrontendTool<{ action: string }> = {
347
+ name: "noFollowUpTool",
348
+ parameters: z.object({ action: z.string() }),
349
+ followUp: false, // This should stop execution after tool call
350
+ render: ({ args, status }) => (
351
+ <div data-testid="no-followup-tool">
352
+ <div data-testid="tool-action">{args.action || "no action"}</div>
353
+ <div data-testid="tool-status">{status}</div>
354
+ </div>
355
+ ),
356
+ };
357
+
358
+ useFrontendTool(tool);
359
+ return null;
360
+ };
361
+
362
+ renderWithCopilotKit({
363
+ agent,
364
+ children: (
365
+ <>
366
+ <NoFollowUpTool />
367
+ <div style={{ height: 400 }}>
368
+ <CopilotChat welcomeScreen={false} />
369
+ </div>
370
+ </>
371
+ ),
372
+ });
373
+
374
+ // Submit a message
375
+ const input = await screen.findByRole("textbox");
376
+ fireEvent.change(input, { target: { value: "Execute no followup" } });
377
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
378
+
379
+ await waitFor(() => {
380
+ expect(screen.getByText("Execute no followup")).toBeDefined();
381
+ });
382
+
383
+ const messageId = testId("msg");
384
+ const toolCallId = testId("tc");
385
+
386
+ // Start run and emit tool call
387
+ agent.emit(runStartedEvent());
388
+ agent.emit(
389
+ toolCallChunkEvent({
390
+ toolCallId,
391
+ toolCallName: "noFollowUpTool",
392
+ parentMessageId: messageId,
393
+ delta: '{"action":"stop-after-this"}',
394
+ }),
395
+ );
396
+
397
+ // Tool should render
398
+ await waitFor(() => {
399
+ expect(screen.getByTestId("no-followup-tool")).toBeDefined();
400
+ expect(screen.getByTestId("tool-action").textContent).toBe(
401
+ "stop-after-this",
402
+ );
403
+ });
404
+
405
+ // The agent should NOT continue after this tool call
406
+ // We can verify this by NOT emitting more events and checking the UI state
407
+ // In a real scenario, the agent would stop sending events
408
+
409
+ agent.emit(runFinishedEvent());
410
+ agent.complete();
411
+
412
+ // Verify execution stopped (no further messages)
413
+ // The chat should only have the user message and tool call, no follow-up
414
+ const messages = screen.queryAllByRole("article");
415
+ expect(messages.length).toBeLessThanOrEqual(2); // User message + tool response
416
+ });
417
+
418
+ it("continues agent execution when followUp is true or undefined", async () => {
419
+ const agent = new MockStepwiseAgent();
420
+
421
+ const ContinueFollowUpTool: React.FC = () => {
422
+ const tool: ReactFrontendTool<{ action: string }> = {
423
+ name: "continueFollowUpTool",
424
+ parameters: z.object({ action: z.string() }),
425
+ // followUp is undefined (default) - should continue execution
426
+ render: ({ args }) => (
427
+ <div data-testid="continue-followup-tool">
428
+ <div data-testid="tool-action">{args.action || "no action"}</div>
429
+ </div>
430
+ ),
431
+ };
432
+
433
+ useFrontendTool(tool);
434
+ return null;
435
+ };
436
+
437
+ renderWithCopilotKit({
438
+ agent,
439
+ children: (
440
+ <>
441
+ <ContinueFollowUpTool />
442
+ <div style={{ height: 400 }}>
443
+ <CopilotChat welcomeScreen={false} />
444
+ </div>
445
+ </>
446
+ ),
447
+ });
448
+
449
+ // Submit a message
450
+ const input = await screen.findByRole("textbox");
451
+ fireEvent.change(input, { target: { value: "Execute with followup" } });
452
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
453
+
454
+ await waitFor(() => {
455
+ expect(screen.getByText("Execute with followup")).toBeDefined();
456
+ });
457
+
458
+ const messageId = testId("msg");
459
+ const toolCallId = testId("tc");
460
+ const followUpMessageId = testId("followup");
461
+
462
+ // Start run and emit tool call
463
+ agent.emit(runStartedEvent());
464
+ agent.emit(
465
+ toolCallChunkEvent({
466
+ toolCallId,
467
+ toolCallName: "continueFollowUpTool",
468
+ parentMessageId: messageId,
469
+ delta: '{"action":"continue-after-this"}',
470
+ }),
471
+ );
472
+
473
+ // Tool should render
474
+ await waitFor(() => {
475
+ expect(screen.getByTestId("continue-followup-tool")).toBeDefined();
476
+ expect(screen.getByTestId("tool-action").textContent).toBe(
477
+ "continue-after-this",
478
+ );
479
+ });
480
+
481
+ // The agent SHOULD continue after this tool call
482
+ // Emit a follow-up message to simulate continued execution
483
+ agent.emit(
484
+ textChunkEvent(
485
+ followUpMessageId,
486
+ "This is a follow-up message after tool execution",
487
+ ),
488
+ );
489
+
490
+ // Verify the follow-up message appears
491
+ await waitFor(() => {
492
+ expect(
493
+ screen.getByText("This is a follow-up message after tool execution"),
494
+ ).toBeDefined();
495
+ });
496
+
497
+ agent.emit(runFinishedEvent());
498
+ agent.complete();
499
+ });
500
+ });
501
+
502
+ describe("Agent input plumbing", () => {
503
+ it("forwards registered frontend tools to runAgent input", async () => {
504
+ class InstrumentedMockAgent extends MockStepwiseAgent {
505
+ public lastRunParameters?: RunAgentParameters;
506
+
507
+ async runAgent(
508
+ parameters?: RunAgentParameters,
509
+ subscriber?: AgentSubscriber,
510
+ ) {
511
+ this.lastRunParameters = parameters;
512
+ return super.runAgent(parameters, subscriber);
513
+ }
514
+ }
515
+
516
+ const agent = new InstrumentedMockAgent();
517
+
518
+ const ToolRegistrar: React.FC = () => {
519
+ const tool: ReactFrontendTool<{ query: string }> = {
520
+ name: "inspectionTool",
521
+ parameters: z.object({ query: z.string() }),
522
+ handler: async ({ query }) => `handled ${query}`,
523
+ };
524
+
525
+ useFrontendTool(tool);
526
+ return null;
527
+ };
528
+
529
+ renderWithCopilotKit({
530
+ agent,
531
+ children: (
532
+ <>
533
+ <ToolRegistrar />
534
+ <div style={{ height: 400 }}>
535
+ <CopilotChat welcomeScreen={false} />
536
+ </div>
537
+ </>
538
+ ),
539
+ });
540
+
541
+ const input = await screen.findByRole("textbox");
542
+ fireEvent.change(input, { target: { value: "Trigger inspection" } });
543
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
544
+
545
+ await waitFor(() => {
546
+ expect(agent.lastRunParameters).toBeDefined();
547
+ });
548
+
549
+ const messageId = testId("msg");
550
+ agent.emit(runStartedEvent());
551
+ agent.emit(
552
+ toolCallResultEvent({
553
+ toolCallId: testId("tc"),
554
+ messageId: `${messageId}_result`,
555
+ content: JSON.stringify({}),
556
+ }),
557
+ );
558
+ agent.emit(runFinishedEvent());
559
+ agent.complete();
560
+
561
+ expect(agent.lastRunParameters?.tools).toBeDefined();
562
+ });
563
+ });
564
+
565
+ describe("Unmount disables handler, render persists", () => {
566
+ it("Tool is properly removed from copilotkit.tools after component unmounts", async () => {
567
+ // A deterministic agent that emits a single tool call per run and finishes
568
+ class OneShotToolCallAgent extends AbstractAgent {
569
+ private runCount = 0;
570
+ clone(): OneShotToolCallAgent {
571
+ // Keep state across runs so the second run emits different args
572
+ return this;
573
+ }
574
+ async detachActiveRun(): Promise<void> {}
575
+ run(_input: RunAgentInput): Observable<BaseEvent> {
576
+ return new Observable<BaseEvent>((observer) => {
577
+ const messageId = testId("m");
578
+ const toolCallId = testId("tc");
579
+ this.runCount += 1;
580
+ const valueArg = this.runCount === 1 ? "first call" : "second call";
581
+ observer.next({ type: EventType.RUN_STARTED } as BaseEvent);
582
+ observer.next({
583
+ type: EventType.TOOL_CALL_CHUNK,
584
+ toolCallId,
585
+ toolCallName: "temporaryTool",
586
+ parentMessageId: messageId,
587
+ delta: JSON.stringify({ value: valueArg }),
588
+ } as BaseEvent);
589
+ observer.next({ type: EventType.RUN_FINISHED } as BaseEvent);
590
+ observer.complete();
591
+ return () => {};
592
+ });
593
+ }
594
+ }
595
+
596
+ const agent = new OneShotToolCallAgent();
597
+ let handlerCalls = 0;
598
+
599
+ // Component that can be toggled on/off
600
+ const ToggleableToolComponent: React.FC = () => {
601
+ const tool: ReactFrontendTool<{ value: string }> = {
602
+ name: "temporaryTool",
603
+ parameters: z.object({ value: z.string() }),
604
+ followUp: false,
605
+ handler: async ({ value }) => {
606
+ handlerCalls += 1;
607
+ return `HANDLED ${value.toUpperCase()}`;
608
+ },
609
+ render: ({ name, args, result, status }) => (
610
+ <div data-testid="temporary-tool">
611
+ {name}: {args.value} | Status: {status} | Result:{" "}
612
+ {String(result ?? "")}
613
+ </div>
614
+ ),
615
+ };
616
+ useFrontendTool(tool);
617
+ return <div data-testid="tool-mounted">Tool is mounted</div>;
618
+ };
619
+
620
+ const TestWrapper: React.FC = () => {
621
+ const [showTool, setShowTool] = useState(true);
622
+ return (
623
+ <>
624
+ <button
625
+ onClick={() => setShowTool(!showTool)}
626
+ data-testid="toggle-button"
627
+ >
628
+ Toggle Tool
629
+ </button>
630
+ {showTool && <ToggleableToolComponent />}
631
+ <div style={{ height: 400 }}>
632
+ <CopilotChat welcomeScreen={false} />
633
+ </div>
634
+ </>
635
+ );
636
+ };
637
+
638
+ renderWithCopilotKit({ agent, children: <TestWrapper /> });
639
+
640
+ // Tool should be mounted initially
641
+ expect(screen.getByTestId("tool-mounted")).toBeDefined();
642
+
643
+ // Run 1: submit a message to trigger agent run with "first call"
644
+ const input = await screen.findByRole("textbox");
645
+ fireEvent.change(input, { target: { value: "Trigger 1" } });
646
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
647
+
648
+ // The tool should render and handler should have produced a result
649
+ await waitFor(() => {
650
+ const toolRender = screen.getByTestId("temporary-tool");
651
+ expect(toolRender.textContent).toContain("first call");
652
+ expect(toolRender.textContent).toContain("HANDLED FIRST CALL");
653
+ expect(handlerCalls).toBe(1);
654
+ });
655
+
656
+ // Unmount the tool component (removes handler but keeps renderer via hook policy)
657
+ fireEvent.click(screen.getByTestId("toggle-button"));
658
+ await waitFor(() => {
659
+ expect(screen.queryByTestId("tool-mounted")).toBeNull();
660
+ });
661
+
662
+ // Run 2: trigger agent again with "second call"
663
+ fireEvent.change(input, { target: { value: "Trigger 2" } });
664
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
665
+
666
+ // The renderer should still render with new args, but no handler result should be produced
667
+ await waitFor(() => {
668
+ const toolRender = screen.getAllByTestId("temporary-tool");
669
+ // There will be two renders in the chat history; check the last one
670
+ const last = toolRender[toolRender.length - 1];
671
+ expect(last?.textContent).toContain("second call");
672
+ // The handler should not have been called a second time since tool was removed
673
+ expect(handlerCalls).toBe(1);
674
+ });
675
+ });
676
+ });
677
+
678
+ describe("Override behavior", () => {
679
+ it("should use latest registration when same tool name is registered multiple times", async () => {
680
+ const agent = new MockStepwiseAgent();
681
+
682
+ // First component with initial tool definition
683
+ const FirstToolComponent: React.FC = () => {
684
+ const tool: ReactFrontendTool<{ text: string }> = {
685
+ name: "overridableTool",
686
+ parameters: z.object({ text: z.string() }),
687
+ render: ({ name, args }) => (
688
+ <div data-testid="first-version">
689
+ First Version: {args.text} ({name})
690
+ </div>
691
+ ),
692
+ };
693
+
694
+ useFrontendTool(tool);
695
+ return null;
696
+ };
697
+
698
+ // Second component with override tool definition
699
+ const SecondToolComponent: React.FC<{ isActive: boolean }> = ({
700
+ isActive,
701
+ }) => {
702
+ if (!isActive) return null;
703
+
704
+ const tool: ReactFrontendTool<{ text: string }> = {
705
+ name: "overridableTool",
706
+ parameters: z.object({ text: z.string() }),
707
+ render: ({ name, args }) => (
708
+ <div data-testid="second-version">
709
+ Second Version (Override): {args.text} ({name})
710
+ </div>
711
+ ),
712
+ };
713
+
714
+ useFrontendTool(tool);
715
+ return null;
716
+ };
717
+
718
+ const TestWrapper: React.FC = () => {
719
+ const [showSecond, setShowSecond] = useState(false);
720
+
721
+ return (
722
+ <>
723
+ <FirstToolComponent />
724
+ <SecondToolComponent isActive={showSecond} />
725
+ <button
726
+ onClick={() => setShowSecond(true)}
727
+ data-testid="activate-override"
728
+ >
729
+ Activate Override
730
+ </button>
731
+ <div style={{ height: 400 }}>
732
+ <CopilotChat welcomeScreen={false} />
733
+ </div>
734
+ </>
735
+ );
736
+ };
737
+
738
+ renderWithCopilotKit({
739
+ agent,
740
+ children: <TestWrapper />,
741
+ });
742
+
743
+ // Submit message before override
744
+ const input = await screen.findByRole("textbox");
745
+ fireEvent.change(input, { target: { value: "Test original" } });
746
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
747
+
748
+ // Wait for message to be processed
749
+ await waitFor(() => {
750
+ expect(screen.getByText("Test original")).toBeDefined();
751
+ });
752
+
753
+ const messageId1 = testId("msg1");
754
+ const toolCallId1 = testId("tc1");
755
+
756
+ agent.emit(runStartedEvent());
757
+ agent.emit(
758
+ toolCallChunkEvent({
759
+ toolCallId: toolCallId1,
760
+ toolCallName: "overridableTool",
761
+ parentMessageId: messageId1,
762
+ delta: '{"text":"before override"}',
763
+ }),
764
+ );
765
+
766
+ // First version should render
767
+ await waitFor(() => {
768
+ const firstVersion = screen.getByTestId("first-version");
769
+ expect(firstVersion.textContent).toContain("before override");
770
+ });
771
+
772
+ agent.emit(runFinishedEvent());
773
+
774
+ // Activate the override
775
+ const overrideButton = screen.getByTestId("activate-override");
776
+ fireEvent.click(overrideButton);
777
+
778
+ // Submit another message after override
779
+ fireEvent.change(input, { target: { value: "Test override" } });
780
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
781
+
782
+ // Wait for message to be processed
783
+ await waitFor(() => {
784
+ expect(screen.getByText("Test override")).toBeDefined();
785
+ });
786
+
787
+ const messageId2 = testId("msg2");
788
+ const toolCallId2 = testId("tc2");
789
+
790
+ agent.emit(runStartedEvent());
791
+ agent.emit(
792
+ toolCallChunkEvent({
793
+ toolCallId: toolCallId2,
794
+ toolCallName: "overridableTool",
795
+ parentMessageId: messageId2,
796
+ delta: '{"text":"after override"}',
797
+ }),
798
+ );
799
+
800
+ // Second version should render (override) - there might be multiple due to both tool calls
801
+ await waitFor(() => {
802
+ const secondVersions = screen.getAllByTestId("second-version");
803
+ // Find the one with "after override"
804
+ const afterOverride = secondVersions.find((el) =>
805
+ el.textContent?.includes("after override"),
806
+ );
807
+ expect(afterOverride).toBeDefined();
808
+ expect(afterOverride?.textContent).toContain("after override");
809
+ });
810
+
811
+ agent.emit(runFinishedEvent());
812
+ agent.complete();
813
+ });
814
+ });
815
+
816
+ describe("Integration with Chat UI", () => {
817
+ it("should render tool output correctly in chat interface", async () => {
818
+ const agent = new MockStepwiseAgent();
819
+
820
+ const IntegratedToolComponent: React.FC = () => {
821
+ const tool: ReactFrontendTool<{ action: string; target: string }> = {
822
+ name: "chatIntegratedTool",
823
+ parameters: z.object({
824
+ action: z.string(),
825
+ target: z.string(),
826
+ }),
827
+ render: ({ name, args, result, status }) => (
828
+ <div data-testid="integrated-tool" className="tool-render">
829
+ <div>Tool: {name}</div>
830
+ <div>Action: {args.action}</div>
831
+ <div>Target: {args.target}</div>
832
+ <div>Status: {status}</div>
833
+ {result && <div>Result: {JSON.stringify(result)}</div>}
834
+ </div>
835
+ ),
836
+ handler: async (args) => {
837
+ return {
838
+ success: true,
839
+ message: `${args.action} completed on ${args.target}`,
840
+ };
841
+ },
842
+ };
843
+
844
+ useFrontendTool(tool);
845
+ return null;
846
+ };
847
+
848
+ renderWithCopilotKit({
849
+ agent,
850
+ children: (
851
+ <>
852
+ <IntegratedToolComponent />
853
+ <div style={{ height: 400 }}>
854
+ <CopilotChat welcomeScreen={false} />
855
+ </div>
856
+ </>
857
+ ),
858
+ });
859
+
860
+ // Submit user message
861
+ const input = await screen.findByRole("textbox");
862
+ fireEvent.change(input, { target: { value: "Perform an action" } });
863
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
864
+
865
+ // User message should appear in chat
866
+ await waitFor(() => {
867
+ expect(screen.getByText("Perform an action")).toBeDefined();
868
+ });
869
+
870
+ const messageId = testId("msg");
871
+ const toolCallId = testId("tc");
872
+
873
+ // Stream tool call
874
+ agent.emit(runStartedEvent());
875
+ agent.emit(
876
+ toolCallChunkEvent({
877
+ toolCallId,
878
+ toolCallName: "chatIntegratedTool",
879
+ parentMessageId: messageId,
880
+ delta: '{"action":"process","target":"data"}',
881
+ }),
882
+ );
883
+
884
+ // Tool should render in chat with proper styling
885
+ await waitFor(() => {
886
+ const toolRender = screen.getByTestId("integrated-tool");
887
+ expect(toolRender).toBeDefined();
888
+ expect(toolRender.textContent).toContain("Action: process");
889
+ expect(toolRender.textContent).toContain("Target: data");
890
+ expect(toolRender.classList.contains("tool-render")).toBe(true);
891
+ });
892
+
893
+ // Send result
894
+ agent.emit(
895
+ toolCallResultEvent({
896
+ toolCallId,
897
+ messageId: `${messageId}_result`,
898
+ content: JSON.stringify({
899
+ success: true,
900
+ message: "process completed on data",
901
+ }),
902
+ }),
903
+ );
904
+
905
+ // Result should appear in the tool render
906
+ await waitFor(() => {
907
+ const toolRender = screen.getByTestId("integrated-tool");
908
+ expect(toolRender.textContent).toContain("Result:");
909
+ expect(toolRender.textContent).toContain("process completed on data");
910
+ });
911
+
912
+ agent.emit(runFinishedEvent());
913
+ agent.complete();
914
+ });
915
+ });
916
+
917
+ describe("Tool Executing State", () => {
918
+ it("should be in executing state while handler is running", async () => {
919
+ const statusHistory: ToolCallStatus[] = [];
920
+ let handlerStarted = false;
921
+ let handlerCompleted = false;
922
+ let handlerResult: any = null;
923
+
924
+ // We'll use a custom agent that tracks when tool handlers execute
925
+ const agent = new MockStepwiseAgent();
926
+
927
+ const ExecutingStateTool: React.FC = () => {
928
+ const tool: ReactFrontendTool<{ value: string }> = {
929
+ name: "executingStateTool",
930
+ parameters: z.object({ value: z.string() }),
931
+ render: ({ args, status, result }) => {
932
+ // Track all status changes
933
+ useEffect(() => {
934
+ if (!statusHistory.includes(status)) {
935
+ statusHistory.push(status);
936
+ }
937
+ }, [status]);
938
+
939
+ return (
940
+ <div data-testid="executing-tool">
941
+ <div data-testid="tool-status">{status}</div>
942
+ <div data-testid="tool-value">{args.value || "undefined"}</div>
943
+ <div data-testid="tool-result">
944
+ {result ? JSON.stringify(result) : "no-result"}
945
+ </div>
946
+ </div>
947
+ );
948
+ },
949
+ handler: async (args) => {
950
+ handlerStarted = true;
951
+ // Simulate async work to allow React to re-render with Executing status
952
+ await new Promise((resolve) => setTimeout(resolve, 50));
953
+ handlerCompleted = true;
954
+ handlerResult = { processed: args.value.toUpperCase() };
955
+ return handlerResult;
956
+ },
957
+ };
958
+
959
+ useFrontendTool(tool);
960
+
961
+ // No need for subscription here - the hook already subscribes internally
962
+
963
+ return null;
964
+ };
965
+
966
+ renderWithCopilotKit({
967
+ agent,
968
+ children: (
969
+ <>
970
+ <ExecutingStateTool />
971
+ <div style={{ height: 400 }}>
972
+ <CopilotChat welcomeScreen={false} />
973
+ </div>
974
+ </>
975
+ ),
976
+ });
977
+
978
+ // Submit message to trigger agent.runAgent
979
+ const input = await screen.findByRole("textbox");
980
+ fireEvent.change(input, { target: { value: "Test executing state" } });
981
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
982
+
983
+ // Wait for message to appear
984
+ await waitFor(() => {
985
+ expect(screen.getByText("Test executing state")).toBeDefined();
986
+ });
987
+
988
+ // Emit tool call events from the agent
989
+ const messageId = testId("msg");
990
+ const toolCallId = testId("tc");
991
+
992
+ agent.emit(runStartedEvent());
993
+ agent.emit(
994
+ toolCallChunkEvent({
995
+ toolCallId,
996
+ toolCallName: "executingStateTool",
997
+ parentMessageId: messageId,
998
+ delta: '{"value":"test"}',
999
+ }),
1000
+ );
1001
+
1002
+ // Wait for tool to render with InProgress status
1003
+ await waitFor(() => {
1004
+ const toolEl = screen.getByTestId("executing-tool");
1005
+ expect(toolEl).toBeDefined();
1006
+ expect(screen.getByTestId("tool-value").textContent).toBe("test");
1007
+ expect(screen.getByTestId("tool-status").textContent).toBe(
1008
+ ToolCallStatus.InProgress,
1009
+ );
1010
+ });
1011
+
1012
+ agent.emit(runFinishedEvent());
1013
+
1014
+ // Complete the agent to trigger handler execution
1015
+ agent.complete();
1016
+
1017
+ // Trigger another run to process the tool
1018
+ await waitFor(
1019
+ async () => {
1020
+ // The handler should start executing
1021
+ expect(handlerStarted).toBe(true);
1022
+ },
1023
+ { timeout: 3000 },
1024
+ );
1025
+ // Wait for handler to complete
1026
+ await waitFor(
1027
+ () => {
1028
+ expect(handlerCompleted).toBe(true);
1029
+ },
1030
+ { timeout: 3000 },
1031
+ );
1032
+ // Verify the handler executed
1033
+ expect(handlerStarted).toBe(true);
1034
+ expect(handlerCompleted).toBe(true);
1035
+ expect(handlerResult).toEqual({ processed: "TEST" });
1036
+
1037
+ // Wait for status to transition to Complete (React re-render cycle)
1038
+ await waitFor(
1039
+ () => {
1040
+ expect(statusHistory).toContain(ToolCallStatus.Complete);
1041
+ },
1042
+ { timeout: 3000 },
1043
+ );
1044
+
1045
+ // Verify we captured all three states in the correct order
1046
+ expect(statusHistory).toContain(ToolCallStatus.InProgress);
1047
+ expect(statusHistory).toContain(ToolCallStatus.Executing);
1048
+
1049
+ // Verify the order is correct
1050
+ const inProgressIndex = statusHistory.indexOf(ToolCallStatus.InProgress);
1051
+ const executingIndex = statusHistory.indexOf(ToolCallStatus.Executing);
1052
+ const completeIndex = statusHistory.indexOf(ToolCallStatus.Complete);
1053
+
1054
+ expect(inProgressIndex).toBeGreaterThanOrEqual(0);
1055
+ expect(executingIndex).toBeGreaterThan(inProgressIndex);
1056
+ expect(completeIndex).toBeGreaterThan(executingIndex);
1057
+ });
1058
+ });
1059
+
1060
+ describe("Agent Scoping", () => {
1061
+ it("supports multiple tools with same name but different agentId", async () => {
1062
+ // Track which handlers are called
1063
+ let defaultAgentHandlerCalled = false;
1064
+ let specificAgentHandlerCalled = false;
1065
+ let wrongAgentHandlerCalled = false;
1066
+
1067
+ // We'll test with the default agent
1068
+ const agent = new MockStepwiseAgent();
1069
+
1070
+ // Tool scoped to "wrongAgent" - should NOT execute
1071
+ const WrongAgentTool: React.FC = () => {
1072
+ const tool: ReactFrontendTool<{ message: string }> = {
1073
+ name: "testTool", // Same name as other tools
1074
+ parameters: z.object({ message: z.string() }),
1075
+ agentId: "wrongAgent", // Different agent
1076
+ render: ({ args }) => (
1077
+ <div data-testid="wrong-agent-tool">
1078
+ Wrong Agent Tool: {args.message}
1079
+ </div>
1080
+ ),
1081
+ handler: async (args) => {
1082
+ wrongAgentHandlerCalled = true;
1083
+ return { result: `Wrong agent processed: ${args.message}` };
1084
+ },
1085
+ };
1086
+ useFrontendTool(tool);
1087
+ return null;
1088
+ };
1089
+
1090
+ // Tool scoped to "default" agent - SHOULD execute
1091
+ const DefaultAgentTool: React.FC = () => {
1092
+ const tool: ReactFrontendTool<{ message: string }> = {
1093
+ name: "testTool", // Same name
1094
+ parameters: z.object({ message: z.string() }),
1095
+ agentId: "default", // Matches our test agent
1096
+ render: ({ args, result }) => (
1097
+ <div data-testid="default-agent-tool">
1098
+ Default Agent Tool: {args.message}
1099
+ {result && (
1100
+ <div data-testid="default-result">{JSON.stringify(result)}</div>
1101
+ )}
1102
+ </div>
1103
+ ),
1104
+ handler: async (args) => {
1105
+ defaultAgentHandlerCalled = true;
1106
+ return { result: `Default agent processed: ${args.message}` };
1107
+ },
1108
+ };
1109
+ useFrontendTool(tool);
1110
+ return null;
1111
+ };
1112
+
1113
+ // Tool scoped to "specificAgent" - should NOT execute
1114
+ const SpecificAgentTool: React.FC = () => {
1115
+ const tool: ReactFrontendTool<{ message: string }> = {
1116
+ name: "testTool", // Same name again
1117
+ parameters: z.object({ message: z.string() }),
1118
+ agentId: "specificAgent", // Different agent
1119
+ render: ({ args }) => (
1120
+ <div data-testid="specific-agent-tool">
1121
+ Specific Agent Tool: {args.message}
1122
+ </div>
1123
+ ),
1124
+ handler: async (args) => {
1125
+ specificAgentHandlerCalled = true;
1126
+ return { result: `Specific agent processed: ${args.message}` };
1127
+ },
1128
+ };
1129
+ useFrontendTool(tool);
1130
+ return null;
1131
+ };
1132
+
1133
+ renderWithCopilotKit({
1134
+ agent,
1135
+ children: (
1136
+ <>
1137
+ <WrongAgentTool />
1138
+ <DefaultAgentTool />
1139
+ <SpecificAgentTool />
1140
+ <div style={{ height: 400 }}>
1141
+ <CopilotChat agentId="default" />
1142
+ </div>
1143
+ </>
1144
+ ),
1145
+ });
1146
+
1147
+ // Submit message to trigger tools
1148
+ const input = await screen.findByRole("textbox");
1149
+ fireEvent.change(input, { target: { value: "Test agent scoping" } });
1150
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1151
+
1152
+ await waitFor(() => {
1153
+ expect(screen.getByText("Test agent scoping")).toBeDefined();
1154
+ });
1155
+
1156
+ const messageId = testId("msg");
1157
+ const toolCallId = testId("tc");
1158
+
1159
+ // Call "testTool" - multiple tools have this name but only the one
1160
+ // scoped to "default" agent should execute its handler
1161
+ agent.emit(runStartedEvent());
1162
+ agent.emit(
1163
+ toolCallChunkEvent({
1164
+ toolCallId,
1165
+ toolCallName: "testTool",
1166
+ parentMessageId: messageId,
1167
+ delta: '{"message":"test message"}',
1168
+ }),
1169
+ );
1170
+ agent.emit(runFinishedEvent());
1171
+
1172
+ // Wait for tool to render - the correct renderer should be used
1173
+ await waitFor(() => {
1174
+ // The default agent tool should render (it's scoped to our agent)
1175
+ const defaultTool = screen.queryByTestId("default-agent-tool");
1176
+ expect(defaultTool).not.toBeNull();
1177
+ expect(defaultTool!.textContent).toContain("test message");
1178
+ });
1179
+
1180
+ // Complete the agent to trigger handler execution
1181
+ agent.complete();
1182
+
1183
+ // Wait for handler execution
1184
+ await waitFor(() => {
1185
+ // Only the default agent handler should be called
1186
+ expect(defaultAgentHandlerCalled).toBe(true);
1187
+ });
1188
+
1189
+ // Log which handlers were called
1190
+ console.log("Handler calls:", {
1191
+ defaultAgent: defaultAgentHandlerCalled,
1192
+ wrongAgent: wrongAgentHandlerCalled,
1193
+ specificAgent: specificAgentHandlerCalled,
1194
+ });
1195
+
1196
+ // Verify the correct handler was executed and others weren't
1197
+ expect(defaultAgentHandlerCalled).toBe(true);
1198
+ expect(wrongAgentHandlerCalled).toBe(false);
1199
+ expect(specificAgentHandlerCalled).toBe(false);
1200
+
1201
+ // Debug: Check what's actually rendered
1202
+ const defaultTool = screen.queryByTestId("default-agent-tool");
1203
+ const wrongTool = screen.queryByTestId("wrong-agent-tool");
1204
+ const specificTool = screen.queryByTestId("specific-agent-tool");
1205
+
1206
+ console.log("Tools rendered:", {
1207
+ default: defaultTool ? "yes" : "no",
1208
+ wrong: wrongTool ? "yes" : "no",
1209
+ specific: specificTool ? "yes" : "no",
1210
+ });
1211
+
1212
+ // Check if result is displayed
1213
+ const resultEl = screen.queryByTestId("default-result");
1214
+ if (resultEl) {
1215
+ console.log("Result element found:", resultEl.textContent);
1216
+ } else {
1217
+ console.log("No result element found");
1218
+ }
1219
+
1220
+ // The test reveals whether agent scoping works correctly
1221
+ // If the wrong tool's handler is called, this is a bug in core
1222
+ });
1223
+
1224
+ it("demonstrates that agent scoping prevents execution of tools for wrong agents", async () => {
1225
+ // This simpler test shows that agent scoping does work for preventing execution
1226
+ let scopedHandlerCalled = false;
1227
+ let globalHandlerCalled = false;
1228
+
1229
+ const agent = new MockStepwiseAgent();
1230
+
1231
+ // Tool scoped to a different agent - should NOT execute
1232
+ const ScopedTool: React.FC = () => {
1233
+ const tool: ReactFrontendTool<{ message: string }> = {
1234
+ name: "scopedTool",
1235
+ parameters: z.object({ message: z.string() }),
1236
+ agentId: "differentAgent", // Different from default
1237
+ render: ({ args, result }) => (
1238
+ <div data-testid="scoped-tool">
1239
+ Scoped Tool: {args.message}
1240
+ {result && (
1241
+ <div data-testid="scoped-result">{JSON.stringify(result)}</div>
1242
+ )}
1243
+ </div>
1244
+ ),
1245
+ handler: async (args) => {
1246
+ scopedHandlerCalled = true;
1247
+ return { result: `Scoped processed: ${args.message}` };
1248
+ },
1249
+ };
1250
+ useFrontendTool(tool);
1251
+ return null;
1252
+ };
1253
+
1254
+ // Global tool (no agentId) - SHOULD execute for any agent
1255
+ const GlobalTool: React.FC = () => {
1256
+ const tool: ReactFrontendTool<{ message: string }> = {
1257
+ name: "globalTool",
1258
+ parameters: z.object({ message: z.string() }),
1259
+ // No agentId - available to all agents
1260
+ render: ({ args, result }) => (
1261
+ <div data-testid="global-tool">
1262
+ Global Tool: {args.message}
1263
+ {result && (
1264
+ <div data-testid="global-result">{JSON.stringify(result)}</div>
1265
+ )}
1266
+ </div>
1267
+ ),
1268
+ handler: async (args) => {
1269
+ globalHandlerCalled = true;
1270
+ return { result: `Global processed: ${args.message}` };
1271
+ },
1272
+ };
1273
+ useFrontendTool(tool);
1274
+ return null;
1275
+ };
1276
+
1277
+ renderWithCopilotKit({
1278
+ agent,
1279
+ children: (
1280
+ <>
1281
+ <ScopedTool />
1282
+ <GlobalTool />
1283
+ <div style={{ height: 400 }}>
1284
+ <CopilotChat agentId="default" />
1285
+ </div>
1286
+ </>
1287
+ ),
1288
+ });
1289
+
1290
+ // Submit message
1291
+ const input = await screen.findByRole("textbox");
1292
+ fireEvent.change(input, { target: { value: "Test scoping" } });
1293
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1294
+
1295
+ await waitFor(() => {
1296
+ expect(screen.getByText("Test scoping")).toBeDefined();
1297
+ });
1298
+
1299
+ const messageId = testId("msg");
1300
+
1301
+ // Try to call the scoped tool - handler should NOT execute
1302
+ agent.emit(runStartedEvent());
1303
+ agent.emit(
1304
+ toolCallChunkEvent({
1305
+ toolCallId: testId("tc1"),
1306
+ toolCallName: "scopedTool",
1307
+ parentMessageId: messageId,
1308
+ delta: '{"message":"trying scoped"}',
1309
+ }),
1310
+ );
1311
+
1312
+ // Tool should render (renderer is always shown)
1313
+ await waitFor(() => {
1314
+ expect(screen.getByTestId("scoped-tool")).toBeDefined();
1315
+ });
1316
+
1317
+ // Call the global tool - handler SHOULD execute
1318
+ agent.emit(
1319
+ toolCallChunkEvent({
1320
+ toolCallId: testId("tc2"),
1321
+ toolCallName: "globalTool",
1322
+ parentMessageId: messageId,
1323
+ delta: '{"message":"trying global"}',
1324
+ }),
1325
+ );
1326
+
1327
+ await waitFor(() => {
1328
+ expect(screen.getByTestId("global-tool")).toBeDefined();
1329
+ });
1330
+
1331
+ agent.emit(runFinishedEvent());
1332
+ agent.complete();
1333
+
1334
+ // Wait for the global handler to be called
1335
+ await waitFor(() => {
1336
+ expect(globalHandlerCalled).toBe(true);
1337
+ });
1338
+
1339
+ // Verify that only the global handler was called
1340
+ expect(scopedHandlerCalled).toBe(false); // Should NOT be called (wrong agent)
1341
+ expect(globalHandlerCalled).toBe(true); // Should be called (no agent restriction)
1342
+
1343
+ // The scoped tool should render but have no result
1344
+ const scopedResult = screen.queryByTestId("scoped-result");
1345
+ expect(scopedResult).toBeNull();
1346
+
1347
+ // The global tool should have a result
1348
+ await waitFor(() => {
1349
+ const globalResult = screen.getByTestId("global-result");
1350
+ expect(globalResult.textContent).toContain(
1351
+ "Global processed: trying global",
1352
+ );
1353
+ });
1354
+ });
1355
+ });
1356
+
1357
+ describe("Nested Tool Calls", () => {
1358
+ it("should enable tool calls that render other tools", async () => {
1359
+ const agent = new MockStepwiseAgent();
1360
+ let childToolRegistered = false;
1361
+
1362
+ // Simple approach: both tools registered at top level
1363
+ // but one triggers the other through tool calls
1364
+ const ChildTool: React.FC = () => {
1365
+ const tool: ReactFrontendTool<{ childValue: string }> = {
1366
+ name: "childTool",
1367
+ parameters: z.object({ childValue: z.string() }),
1368
+ render: ({ args }) => (
1369
+ <div data-testid="child-tool">Child: {args.childValue}</div>
1370
+ ),
1371
+ };
1372
+
1373
+ useFrontendTool(tool);
1374
+
1375
+ useEffect(() => {
1376
+ childToolRegistered = true;
1377
+ }, []);
1378
+
1379
+ return null;
1380
+ };
1381
+
1382
+ const ParentTool: React.FC = () => {
1383
+ const tool: ReactFrontendTool<{ parentValue: string }> = {
1384
+ name: "parentTool",
1385
+ parameters: z.object({ parentValue: z.string() }),
1386
+ render: ({ args }) => (
1387
+ <div data-testid="parent-tool">Parent: {args.parentValue}</div>
1388
+ ),
1389
+ };
1390
+
1391
+ useFrontendTool(tool);
1392
+ return null;
1393
+ };
1394
+
1395
+ renderWithCopilotKit({
1396
+ agent,
1397
+ children: (
1398
+ <>
1399
+ <ParentTool />
1400
+ <ChildTool />
1401
+ <div style={{ height: 400 }}>
1402
+ <CopilotChat welcomeScreen={false} />
1403
+ </div>
1404
+ </>
1405
+ ),
1406
+ });
1407
+
1408
+ // Verify both tools are registered
1409
+ expect(childToolRegistered).toBe(true);
1410
+
1411
+ // Submit message
1412
+ const input = await screen.findByRole("textbox");
1413
+ fireEvent.change(input, { target: { value: "Test nested tools" } });
1414
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1415
+
1416
+ await waitFor(() => {
1417
+ expect(screen.getByText("Test nested tools")).toBeDefined();
1418
+ });
1419
+
1420
+ const messageId = testId("msg");
1421
+
1422
+ // Call parent tool
1423
+ agent.emit(runStartedEvent());
1424
+ agent.emit(
1425
+ toolCallChunkEvent({
1426
+ toolCallId: testId("parent-tc"),
1427
+ toolCallName: "parentTool",
1428
+ parentMessageId: messageId,
1429
+ delta: '{"parentValue":"test parent"}',
1430
+ }),
1431
+ );
1432
+
1433
+ // Parent tool should render
1434
+ await waitFor(() => {
1435
+ expect(screen.getByTestId("parent-tool")).toBeDefined();
1436
+ });
1437
+
1438
+ // Now call the child tool (simulating nested call)
1439
+ agent.emit(
1440
+ toolCallChunkEvent({
1441
+ toolCallId: testId("child-tc"),
1442
+ toolCallName: "childTool",
1443
+ parentMessageId: messageId,
1444
+ delta: '{"childValue":"test child"}',
1445
+ }),
1446
+ );
1447
+
1448
+ // Child tool should render
1449
+ await waitFor(() => {
1450
+ expect(screen.getByTestId("child-tool")).toBeDefined();
1451
+ expect(screen.getByTestId("child-tool").textContent).toContain(
1452
+ "test child",
1453
+ );
1454
+ });
1455
+
1456
+ agent.emit(runFinishedEvent());
1457
+ agent.complete();
1458
+ });
1459
+ });
1460
+
1461
+ describe("Tool Availability", () => {
1462
+ it("should ensure tools are available when request is made", async () => {
1463
+ const agent = new MockStepwiseAgent();
1464
+
1465
+ const AvailabilityTestTool: React.FC<{ onRegistered?: () => void }> = ({
1466
+ onRegistered,
1467
+ }) => {
1468
+ const tool: ReactFrontendTool<{ test: string }> = {
1469
+ name: "availabilityTool",
1470
+ parameters: z.object({ test: z.string() }),
1471
+ render: ({ args }) => (
1472
+ <div data-testid="availability-tool">{args.test}</div>
1473
+ ),
1474
+ handler: async (args) => ({ received: args.test }),
1475
+ };
1476
+
1477
+ useFrontendTool(tool);
1478
+
1479
+ // Notify when registered
1480
+ useEffect(() => {
1481
+ onRegistered?.();
1482
+ }, [onRegistered]);
1483
+
1484
+ return null;
1485
+ };
1486
+
1487
+ let toolRegistered = false;
1488
+ const onRegistered = () => {
1489
+ toolRegistered = true;
1490
+ };
1491
+
1492
+ renderWithCopilotKit({
1493
+ agent,
1494
+ children: (
1495
+ <>
1496
+ <AvailabilityTestTool onRegistered={onRegistered} />
1497
+ <div style={{ height: 400 }}>
1498
+ <CopilotChat welcomeScreen={false} />
1499
+ </div>
1500
+ </>
1501
+ ),
1502
+ });
1503
+
1504
+ // Tool should be available immediately after mounting
1505
+ await waitFor(() => {
1506
+ expect(toolRegistered).toBe(true);
1507
+ });
1508
+
1509
+ // Verify tool is in copilotkit.tools
1510
+ // Note: We can't directly access copilotkit.tools from here,
1511
+ // but we can verify it works by calling it
1512
+ const input = await screen.findByRole("textbox");
1513
+ fireEvent.change(input, { target: { value: "Test availability" } });
1514
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1515
+
1516
+ await waitFor(() => {
1517
+ expect(screen.getByText("Test availability")).toBeDefined();
1518
+ });
1519
+
1520
+ // Tool call should work immediately
1521
+ agent.emit(runStartedEvent());
1522
+ agent.emit(
1523
+ toolCallChunkEvent({
1524
+ toolCallId: testId("tc"),
1525
+ toolCallName: "availabilityTool",
1526
+ parentMessageId: testId("msg"),
1527
+ delta: '{"test":"available"}',
1528
+ }),
1529
+ );
1530
+
1531
+ // Tool should render successfully
1532
+ await waitFor(() => {
1533
+ expect(screen.getByTestId("availability-tool")).toBeDefined();
1534
+ expect(screen.getByTestId("availability-tool").textContent).toBe(
1535
+ "available",
1536
+ );
1537
+ });
1538
+
1539
+ agent.emit(runFinishedEvent());
1540
+ agent.complete();
1541
+ });
1542
+ });
1543
+
1544
+ describe("Re-render Idempotence", () => {
1545
+ it("should not create duplicates on re-render", async () => {
1546
+ const agent = new MockStepwiseAgent();
1547
+ let renderCount = 0;
1548
+
1549
+ const IdempotentTool: React.FC = () => {
1550
+ // Use state to trigger re-renders
1551
+ const [counter, setCounter] = useState(0);
1552
+
1553
+ const tool: ReactFrontendTool<{ value: string }> = {
1554
+ name: "idempotentTool",
1555
+ parameters: z.object({ value: z.string() }),
1556
+ render: ({ args }) => {
1557
+ renderCount++;
1558
+ return (
1559
+ <div data-testid="idempotent-tool">
1560
+ Value: {args.value} | Renders: {renderCount}
1561
+ </div>
1562
+ );
1563
+ },
1564
+ };
1565
+
1566
+ useFrontendTool(tool);
1567
+
1568
+ return (
1569
+ <div>
1570
+ <button
1571
+ data-testid="rerender-button"
1572
+ onClick={() => setCounter((c) => c + 1)}
1573
+ >
1574
+ Re-render ({counter})
1575
+ </button>
1576
+ </div>
1577
+ );
1578
+ };
1579
+
1580
+ renderWithCopilotKit({
1581
+ agent,
1582
+ children: (
1583
+ <>
1584
+ <IdempotentTool />
1585
+ <div style={{ height: 400 }}>
1586
+ <CopilotChat welcomeScreen={false} />
1587
+ </div>
1588
+ </>
1589
+ ),
1590
+ });
1591
+
1592
+ // Submit message
1593
+ const input = await screen.findByRole("textbox");
1594
+ fireEvent.change(input, { target: { value: "Test idempotence" } });
1595
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1596
+
1597
+ await waitFor(() => {
1598
+ expect(screen.getByText("Test idempotence")).toBeDefined();
1599
+ });
1600
+
1601
+ // Emit tool call
1602
+ agent.emit(runStartedEvent());
1603
+ agent.emit(
1604
+ toolCallChunkEvent({
1605
+ toolCallId: testId("tc"),
1606
+ toolCallName: "idempotentTool",
1607
+ parentMessageId: testId("msg"),
1608
+ delta: '{"value":"test"}',
1609
+ }),
1610
+ );
1611
+
1612
+ // Tool should render once
1613
+ await waitFor(() => {
1614
+ const tools = screen.getAllByTestId("idempotent-tool");
1615
+ expect(tools).toHaveLength(1);
1616
+ expect(tools[0]?.textContent).toContain("Value: test");
1617
+ });
1618
+
1619
+ const initialRenderCount = renderCount;
1620
+
1621
+ // Trigger re-render by clicking button
1622
+ fireEvent.click(screen.getByTestId("rerender-button"));
1623
+
1624
+ // Wait for re-render
1625
+ await waitFor(() => {
1626
+ const button = screen.getByTestId("rerender-button");
1627
+ expect(button.textContent).toContain("1");
1628
+ });
1629
+
1630
+ // Tool should still render only once (no duplicate elements)
1631
+ const toolsAfterRerender = screen.getAllByTestId("idempotent-tool");
1632
+ expect(toolsAfterRerender).toHaveLength(1);
1633
+
1634
+ // The render count should not have increased dramatically
1635
+ // (may increase slightly due to React re-renders, but not duplicate the tool)
1636
+ expect(renderCount).toBeLessThanOrEqual(initialRenderCount + 2);
1637
+
1638
+ agent.emit(runFinishedEvent());
1639
+ agent.complete();
1640
+ });
1641
+ });
1642
+
1643
+ describe("useFrontendTool dependencies", () => {
1644
+ it("updates tool renderer when optional deps change", async () => {
1645
+ const DependencyDrivenTool: React.FC = () => {
1646
+ const [version, setVersion] = useState(0);
1647
+
1648
+ const tool: ReactFrontendTool<{ message: string }> = {
1649
+ name: "dependencyTool",
1650
+ parameters: z.object({ message: z.string() }),
1651
+ render: ({ args }) => (
1652
+ <div data-testid="dependency-tool-render">
1653
+ {args.message} (v{version})
1654
+ </div>
1655
+ ),
1656
+ };
1657
+
1658
+ useFrontendTool(tool, [version]);
1659
+
1660
+ const toolCallId = testId("dep_tc");
1661
+ const assistantMessage: AssistantMessage = {
1662
+ id: testId("dep_a"),
1663
+ role: "assistant",
1664
+ content: "",
1665
+ toolCalls: [
1666
+ {
1667
+ id: toolCallId,
1668
+ type: "function",
1669
+ function: {
1670
+ name: "dependencyTool",
1671
+ arguments: JSON.stringify({ message: "hello" }),
1672
+ },
1673
+ } as any,
1674
+ ],
1675
+ } as any;
1676
+ const messages: Message[] = [];
1677
+
1678
+ return (
1679
+ <>
1680
+ <button
1681
+ data-testid="bump-version"
1682
+ type="button"
1683
+ onClick={() => setVersion((v) => v + 1)}
1684
+ >
1685
+ Bump
1686
+ </button>
1687
+ <CopilotChatToolCallsView
1688
+ message={assistantMessage}
1689
+ messages={messages}
1690
+ />
1691
+ </>
1692
+ );
1693
+ };
1694
+
1695
+ renderWithCopilotKit({
1696
+ children: <DependencyDrivenTool />,
1697
+ });
1698
+
1699
+ await waitFor(() => {
1700
+ const el = screen.getByTestId("dependency-tool-render");
1701
+ expect(el).toBeDefined();
1702
+ expect(el.textContent).toContain("hello");
1703
+ expect(el.textContent).toContain("(v0)");
1704
+ });
1705
+
1706
+ fireEvent.click(screen.getByTestId("bump-version"));
1707
+
1708
+ await waitFor(() => {
1709
+ const el = screen.getByTestId("dependency-tool-render");
1710
+ expect(el.textContent).toContain("(v1)");
1711
+ });
1712
+ });
1713
+ });
1714
+
1715
+ describe("Error Propagation", () => {
1716
+ it("should propagate handler errors to renderer", async () => {
1717
+ const agent = new MockStepwiseAgent();
1718
+ let handlerCalled = false;
1719
+ let errorThrown = false;
1720
+
1721
+ const ErrorTool: React.FC = () => {
1722
+ const tool: ReactFrontendTool<{
1723
+ shouldError: boolean;
1724
+ message: string;
1725
+ }> = {
1726
+ name: "errorTool",
1727
+ parameters: z.object({
1728
+ shouldError: z.boolean(),
1729
+ message: z.string(),
1730
+ }),
1731
+ render: ({ args, status, result }) => (
1732
+ <div data-testid="error-tool">
1733
+ <div data-testid="error-status">{status}</div>
1734
+ <div data-testid="error-message">{args.message}</div>
1735
+ <div data-testid="error-result">
1736
+ {result ? String(result) : "no-result"}
1737
+ </div>
1738
+ </div>
1739
+ ),
1740
+ handler: async (args) => {
1741
+ handlerCalled = true;
1742
+ if (args.shouldError) {
1743
+ errorThrown = true;
1744
+ throw new Error(`Handler error: ${args.message}`);
1745
+ }
1746
+ return { success: true, message: args.message };
1747
+ },
1748
+ };
1749
+
1750
+ useFrontendTool(tool);
1751
+ return null;
1752
+ };
1753
+
1754
+ renderWithCopilotKit({
1755
+ agent,
1756
+ children: (
1757
+ <>
1758
+ <ErrorTool />
1759
+ <div style={{ height: 400 }}>
1760
+ <CopilotChat welcomeScreen={false} />
1761
+ </div>
1762
+ </>
1763
+ ),
1764
+ });
1765
+
1766
+ // Submit message
1767
+ const input = await screen.findByRole("textbox");
1768
+ fireEvent.change(input, { target: { value: "Test error" } });
1769
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1770
+
1771
+ await waitFor(() => {
1772
+ expect(screen.getByText("Test error")).toBeDefined();
1773
+ });
1774
+
1775
+ // Emit tool call that will error
1776
+ const messageId = testId("msg");
1777
+ const toolCallId = testId("tc");
1778
+
1779
+ agent.emit(runStartedEvent());
1780
+ agent.emit(
1781
+ toolCallChunkEvent({
1782
+ toolCallId,
1783
+ toolCallName: "errorTool",
1784
+ parentMessageId: messageId,
1785
+ delta: '{"shouldError":true,"message":"test error"}',
1786
+ }),
1787
+ );
1788
+ agent.emit(runFinishedEvent());
1789
+
1790
+ // Wait for tool to render
1791
+ await waitFor(() => {
1792
+ expect(screen.getByTestId("error-tool")).toBeDefined();
1793
+ });
1794
+
1795
+ // Complete the agent to trigger handler execution
1796
+ agent.complete();
1797
+
1798
+ // Wait for handler to be called and error to be thrown
1799
+ await waitFor(() => {
1800
+ expect(handlerCalled).toBe(true);
1801
+ expect(errorThrown).toBe(true);
1802
+ });
1803
+
1804
+ // Wait for the error result to be displayed in the renderer
1805
+ await waitFor(() => {
1806
+ const resultEl = screen.getByTestId("error-result");
1807
+ const resultText = resultEl.textContent || "";
1808
+ expect(resultText).not.toBe("no-result");
1809
+ expect(resultText).toContain("Error:");
1810
+ expect(resultText).toContain("Handler error: test error");
1811
+ });
1812
+
1813
+ // Status should be complete even with error
1814
+ expect(screen.getByTestId("error-status").textContent).toBe(
1815
+ ToolCallStatus.Complete,
1816
+ );
1817
+ });
1818
+
1819
+ it("should handle async errors in handler", async () => {
1820
+ const agent = new MockStepwiseAgent();
1821
+
1822
+ const AsyncErrorTool: React.FC = () => {
1823
+ const tool: ReactFrontendTool<{ delay: number; errorMessage: string }> =
1824
+ {
1825
+ name: "asyncErrorTool",
1826
+ parameters: z.object({
1827
+ delay: z.number(),
1828
+ errorMessage: z.string(),
1829
+ }),
1830
+ render: ({ args, status, result }) => (
1831
+ <div data-testid="async-error-tool">
1832
+ <div data-testid="async-status">{status}</div>
1833
+ <div data-testid="async-delay">Delay: {args.delay}ms</div>
1834
+ <div data-testid="async-error-msg">{args.errorMessage}</div>
1835
+ {result && <div data-testid="async-result">{result}</div>}
1836
+ </div>
1837
+ ),
1838
+ handler: async (args) => {
1839
+ // Simulate async operation
1840
+ await new Promise((resolve) => setTimeout(resolve, args.delay));
1841
+ // In test environment, throwing might not propagate as expected
1842
+ throw new Error(args.errorMessage);
1843
+ },
1844
+ };
1845
+
1846
+ useFrontendTool(tool);
1847
+ return null;
1848
+ };
1849
+
1850
+ renderWithCopilotKit({
1851
+ agent,
1852
+ children: (
1853
+ <>
1854
+ <AsyncErrorTool />
1855
+ <div style={{ height: 400 }}>
1856
+ <CopilotChat welcomeScreen={false} />
1857
+ </div>
1858
+ </>
1859
+ ),
1860
+ });
1861
+
1862
+ // Submit message
1863
+ const input = await screen.findByRole("textbox");
1864
+ fireEvent.change(input, { target: { value: "Test async error" } });
1865
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1866
+
1867
+ await waitFor(() => {
1868
+ expect(screen.getByText("Test async error")).toBeDefined();
1869
+ });
1870
+
1871
+ // Emit tool call that will error after delay
1872
+ agent.emit(runStartedEvent());
1873
+ agent.emit(
1874
+ toolCallChunkEvent({
1875
+ toolCallId: testId("tc"),
1876
+ toolCallName: "asyncErrorTool",
1877
+ parentMessageId: testId("msg"),
1878
+ delta:
1879
+ '{"delay":10,"errorMessage":"Async operation failed after delay"}',
1880
+ }),
1881
+ );
1882
+
1883
+ // Tool should render immediately with args
1884
+ await waitFor(() => {
1885
+ expect(screen.getByTestId("async-error-tool")).toBeDefined();
1886
+ expect(screen.getByTestId("async-delay").textContent).toContain("10ms");
1887
+ expect(screen.getByTestId("async-error-msg").textContent).toContain(
1888
+ "Async operation failed",
1889
+ );
1890
+ });
1891
+
1892
+ // The test verifies that:
1893
+ // 1. Async tools with delays can render immediately
1894
+ // 2. Error messages are properly passed through args
1895
+ // 3. The tool continues to function even with async handlers that may throw
1896
+
1897
+ // In production, the error would be caught and sent as a result
1898
+ // but in test environment, handler execution may not complete
1899
+
1900
+ agent.emit(runFinishedEvent());
1901
+ agent.complete();
1902
+ });
1903
+ });
1904
+
1905
+ describe("Wildcard Handler", () => {
1906
+ it("should handle unknown tools with wildcard", async () => {
1907
+ const agent = new MockStepwiseAgent();
1908
+ const wildcardHandlerCalls: { name: string; args: any }[] = [];
1909
+
1910
+ // Note: Wildcard tools work as fallback renderers when no specific tool is found
1911
+ // The wildcard renderer receives the original tool name and arguments
1912
+ const WildcardTool: React.FC = () => {
1913
+ const tool: ReactFrontendTool<any> = {
1914
+ name: "*",
1915
+ parameters: z.any(),
1916
+ render: ({ name, args, status, result }) => (
1917
+ <div data-testid={`wildcard-render-${name}`}>
1918
+ <div data-testid="wildcard-tool-name">
1919
+ Wildcard caught: {name}
1920
+ </div>
1921
+ <div data-testid="wildcard-args">
1922
+ Args: {JSON.stringify(args)}
1923
+ </div>
1924
+ <div data-testid="wildcard-status">Status: {status}</div>
1925
+ {result && (
1926
+ <div data-testid="wildcard-result">Result: {result}</div>
1927
+ )}
1928
+ </div>
1929
+ ),
1930
+ handler: async (args: any) => {
1931
+ // Track handler calls
1932
+ wildcardHandlerCalls.push({ name: "wildcard", args });
1933
+ return { handled: "by wildcard", receivedArgs: args };
1934
+ },
1935
+ };
1936
+
1937
+ useFrontendTool(tool);
1938
+ return null;
1939
+ };
1940
+
1941
+ renderWithCopilotKit({
1942
+ agent,
1943
+ children: (
1944
+ <>
1945
+ <WildcardTool />
1946
+ <div style={{ height: 400 }}>
1947
+ <CopilotChat welcomeScreen={false} />
1948
+ </div>
1949
+ </>
1950
+ ),
1951
+ });
1952
+
1953
+ // Submit message
1954
+ const input = await screen.findByRole("textbox");
1955
+ fireEvent.change(input, { target: { value: "Test wildcard" } });
1956
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
1957
+
1958
+ await waitFor(() => {
1959
+ expect(screen.getByText("Test wildcard")).toBeDefined();
1960
+ });
1961
+
1962
+ agent.emit(runStartedEvent());
1963
+
1964
+ // Test 1: Call first undefined tool
1965
+ agent.emit(
1966
+ toolCallChunkEvent({
1967
+ toolCallId: testId("tc1"),
1968
+ toolCallName: "undefinedTool",
1969
+ parentMessageId: testId("msg"),
1970
+ delta: '{"someParam":"value","anotherParam":123}',
1971
+ }),
1972
+ );
1973
+
1974
+ // Wildcard should render the unknown tool with correct name and args
1975
+ await waitFor(() => {
1976
+ const nameEl = screen.getByTestId("wildcard-tool-name");
1977
+ expect(nameEl.textContent).toContain("undefinedTool");
1978
+ const argsEl = screen.getByTestId("wildcard-args");
1979
+ expect(argsEl.textContent).toContain("someParam");
1980
+ expect(argsEl.textContent).toContain("value");
1981
+ expect(argsEl.textContent).toContain("123");
1982
+ });
1983
+
1984
+ // Check status is InProgress or Complete
1985
+ await waitFor(() => {
1986
+ const statusEl = screen.getByTestId("wildcard-status");
1987
+ expect(statusEl.textContent).toMatch(/Status: (inProgress|complete)/);
1988
+ });
1989
+
1990
+ // Test 2: Call another undefined tool to verify wildcard catches multiple
1991
+ agent.emit(
1992
+ toolCallChunkEvent({
1993
+ toolCallId: testId("tc2"),
1994
+ toolCallName: "anotherUnknownTool",
1995
+ parentMessageId: testId("msg"),
1996
+ delta: '{"differentArg":"test"}',
1997
+ }),
1998
+ );
1999
+
2000
+ // Should render both unknown tools
2001
+ await waitFor(() => {
2002
+ const tool1 = screen.getByTestId("wildcard-render-undefinedTool");
2003
+ const tool2 = screen.getByTestId("wildcard-render-anotherUnknownTool");
2004
+ expect(tool1).toBeDefined();
2005
+ expect(tool2).toBeDefined();
2006
+ });
2007
+
2008
+ // Send result for first tool
2009
+ agent.emit(
2010
+ toolCallResultEvent({
2011
+ toolCallId: testId("tc1"),
2012
+ messageId: testId("msg_result"),
2013
+ content: "Tool executed successfully",
2014
+ }),
2015
+ );
2016
+
2017
+ // Check result is displayed
2018
+ await waitFor(() => {
2019
+ const resultEl = screen.queryByTestId("wildcard-result");
2020
+ if (resultEl) {
2021
+ expect(resultEl.textContent).toContain("Tool executed successfully");
2022
+ }
2023
+ });
2024
+
2025
+ agent.emit(runFinishedEvent());
2026
+ agent.complete();
2027
+ });
2028
+ });
2029
+
2030
+ describe("Renderer Precedence", () => {
2031
+ it("should use specific renderer over wildcard", async () => {
2032
+ const agent = new MockStepwiseAgent();
2033
+
2034
+ // Specific tool
2035
+ const SpecificTool: React.FC = () => {
2036
+ const tool: ReactFrontendTool<{ value: string }> = {
2037
+ name: "specificTool",
2038
+ parameters: z.object({ value: z.string() }),
2039
+ render: ({ args }) => (
2040
+ <div data-testid="specific-render">Specific: {args.value}</div>
2041
+ ),
2042
+ };
2043
+ useFrontendTool(tool);
2044
+ return null;
2045
+ };
2046
+
2047
+ // Wildcard tool - should only catch unknown tools
2048
+ const WildcardTool: React.FC = () => {
2049
+ const tool: ReactFrontendTool<any> = {
2050
+ name: "*",
2051
+ parameters: z.any(),
2052
+ render: ({ name }) => (
2053
+ <div data-testid="wildcard-render">Wildcard: {name}</div>
2054
+ ),
2055
+ };
2056
+ useFrontendTool(tool);
2057
+ return null;
2058
+ };
2059
+
2060
+ renderWithCopilotKit({
2061
+ agent,
2062
+ children: (
2063
+ <>
2064
+ <SpecificTool />
2065
+ <WildcardTool />
2066
+ <div style={{ height: 400 }}>
2067
+ <CopilotChat welcomeScreen={false} />
2068
+ </div>
2069
+ </>
2070
+ ),
2071
+ });
2072
+
2073
+ // Submit message
2074
+ const input = await screen.findByRole("textbox");
2075
+ fireEvent.change(input, { target: { value: "Test precedence" } });
2076
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
2077
+
2078
+ await waitFor(() => {
2079
+ expect(screen.getByText("Test precedence")).toBeDefined();
2080
+ });
2081
+
2082
+ agent.emit(runStartedEvent());
2083
+
2084
+ // Call specific tool - should use specific renderer
2085
+ agent.emit(
2086
+ toolCallChunkEvent({
2087
+ toolCallId: testId("tc1"),
2088
+ toolCallName: "specificTool",
2089
+ parentMessageId: testId("msg"),
2090
+ delta: '{"value":"test specific"}',
2091
+ }),
2092
+ );
2093
+
2094
+ // Should render with specific renderer, not wildcard
2095
+ await waitFor(() => {
2096
+ expect(screen.getByTestId("specific-render")).toBeDefined();
2097
+ expect(screen.getByTestId("specific-render").textContent).toContain(
2098
+ "test specific",
2099
+ );
2100
+ });
2101
+
2102
+ // Call unknown tool - should use wildcard renderer
2103
+ agent.emit(
2104
+ toolCallChunkEvent({
2105
+ toolCallId: testId("tc2"),
2106
+ toolCallName: "unknownTool",
2107
+ parentMessageId: testId("msg"),
2108
+ delta: '{"someArg":"test wildcard"}',
2109
+ }),
2110
+ );
2111
+
2112
+ // Should render with wildcard renderer
2113
+ await waitFor(() => {
2114
+ const wildcards = screen.getAllByTestId("wildcard-render");
2115
+ expect(wildcards.length).toBeGreaterThan(0);
2116
+ const unknownToolRender = wildcards.find((el) =>
2117
+ el.textContent?.includes("unknownTool"),
2118
+ );
2119
+ expect(unknownToolRender).toBeDefined();
2120
+ });
2121
+
2122
+ // Verify specific tool still used its renderer (not replaced by wildcard)
2123
+ expect(screen.getByTestId("specific-render")).toBeDefined();
2124
+
2125
+ agent.emit(runFinishedEvent());
2126
+ agent.complete();
2127
+ });
2128
+ });
2129
+ });