@copilotkit/react-core 1.54.1 → 1.55.0-next.8
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.
- package/CHANGELOG.md +127 -116
- package/dist/copilotkit-B3Mb1yVE.cjs +7975 -0
- package/dist/copilotkit-B3Mb1yVE.cjs.map +1 -0
- package/dist/copilotkit-DBzgOMby.d.cts +2182 -0
- package/dist/copilotkit-DBzgOMby.d.cts.map +1 -0
- package/dist/copilotkit-DNYSFuz5.mjs +7562 -0
- package/dist/copilotkit-DNYSFuz5.mjs.map +1 -0
- package/dist/copilotkit-Dy5w3qEV.d.mts +2182 -0
- package/dist/copilotkit-Dy5w3qEV.d.mts.map +1 -0
- package/dist/index.cjs +27 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +4 -5
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1941 -35
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +77 -7
- package/dist/v2/index.css +1 -2
- package/dist/v2/index.d.cts +6 -4
- package/dist/v2/index.d.mts +6 -4
- package/dist/v2/index.mjs +7 -4
- package/dist/v2/index.umd.js +5725 -24
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +37 -9
- package/scripts/scope-preflight.mjs +101 -0
- package/src/components/CopilotListeners.tsx +2 -6
- package/src/components/copilot-provider/copilot-messages.tsx +1 -1
- package/src/components/copilot-provider/copilotkit-props.tsx +1 -1
- package/src/components/copilot-provider/copilotkit.tsx +4 -4
- package/src/context/copilot-messages-context.tsx +1 -1
- package/src/hooks/__tests__/use-coagent-config.test.ts +2 -2
- package/src/hooks/__tests__/use-coagent-state-render.e2e.test.tsx +2 -2
- package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +3 -7
- package/src/hooks/__tests__/use-frontend-tool-available.test.tsx +1 -1
- package/src/hooks/__tests__/use-frontend-tool-remount.e2e.test.tsx +4 -4
- package/src/hooks/use-agent-nodename.ts +1 -1
- package/src/hooks/use-coagent-state-render-bridge.tsx +1 -4
- package/src/hooks/use-coagent.ts +1 -1
- package/src/hooks/use-configure-chat-suggestions.tsx +2 -2
- package/src/hooks/use-copilot-chat-suggestions.tsx +2 -2
- package/src/hooks/use-copilot-chat_internal.ts +2 -2
- package/src/hooks/use-copilot-readable.ts +1 -1
- package/src/hooks/use-frontend-tool.ts +2 -2
- package/src/hooks/use-human-in-the-loop.ts +2 -2
- package/src/hooks/use-langgraph-interrupt.ts +2 -5
- package/src/hooks/use-lazy-tool-renderer.tsx +1 -1
- package/src/hooks/use-render-tool-call.ts +1 -1
- package/src/lib/copilot-task.ts +1 -1
- package/src/setupTests.ts +18 -14
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +176 -0
- package/src/v2/__tests__/globalSetup.ts +14 -0
- package/src/v2/__tests__/setup.ts +93 -0
- package/src/v2/__tests__/utils/test-helpers.tsx +470 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +206 -0
- package/src/v2/components/CopilotKitInspector.tsx +50 -0
- package/src/v2/components/MCPAppsActivityRenderer.tsx +785 -0
- package/src/v2/components/WildcardToolCallRender.tsx +86 -0
- package/src/v2/components/__tests__/license-warning-banner.test.tsx +46 -0
- package/src/v2/components/chat/CopilotChat.tsx +431 -0
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +375 -0
- package/src/v2/components/chat/CopilotChatAudioRecorder.tsx +350 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +1302 -0
- package/src/v2/components/chat/CopilotChatMessageView.tsx +556 -0
- package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +252 -0
- package/src/v2/components/chat/CopilotChatSuggestionPill.tsx +59 -0
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +133 -0
- package/src/v2/components/chat/CopilotChatToggleButton.tsx +171 -0
- package/src/v2/components/chat/CopilotChatToolCallsView.tsx +40 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +388 -0
- package/src/v2/components/chat/CopilotChatView.tsx +598 -0
- package/src/v2/components/chat/CopilotModalHeader.tsx +129 -0
- package/src/v2/components/chat/CopilotPopup.tsx +81 -0
- package/src/v2/components/chat/CopilotPopupView.tsx +317 -0
- package/src/v2/components/chat/CopilotSidebar.tsx +76 -0
- package/src/v2/components/chat/CopilotSidebarView.tsx +255 -0
- package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +1113 -0
- package/src/v2/components/chat/__tests__/CopilotChat.onError.test.tsx +73 -0
- package/src/v2/components/chat/__tests__/CopilotChat.slots.e2e.test.tsx +432 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +150 -0
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.slots.e2e.test.tsx +624 -0
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +702 -0
- package/src/v2/components/chat/__tests__/CopilotChatCssClasses.test.tsx +107 -0
- package/src/v2/components/chat/__tests__/CopilotChatInput.slots.e2e.test.tsx +929 -0
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +986 -0
- package/src/v2/components/chat/__tests__/CopilotChatMessageView.slots.e2e.test.tsx +1004 -0
- package/src/v2/components/chat/__tests__/CopilotChatMessageView.test.tsx +169 -0
- package/src/v2/components/chat/__tests__/CopilotChatSuggestionView.slots.e2e.test.tsx +530 -0
- package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +782 -0
- package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +2413 -0
- package/src/v2/components/chat/__tests__/CopilotChatUserMessage.slots.e2e.test.tsx +621 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.onClick.e2e.test.tsx +853 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.slots.e2e.test.tsx +1050 -0
- package/src/v2/components/chat/__tests__/CopilotModalHeader.slots.e2e.test.tsx +484 -0
- package/src/v2/components/chat/__tests__/CopilotPopupView.slots.e2e.test.tsx +612 -0
- package/src/v2/components/chat/__tests__/CopilotSidebarView.slots.e2e.test.tsx +502 -0
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +1011 -0
- package/src/v2/components/chat/__tests__/setup.ts +1 -0
- package/src/v2/components/chat/index.ts +79 -0
- package/src/v2/components/index.ts +7 -0
- package/src/v2/components/license-warning-banner.tsx +198 -0
- package/src/v2/components/ui/button.tsx +123 -0
- package/src/v2/components/ui/dropdown-menu.tsx +258 -0
- package/src/v2/components/ui/tooltip.tsx +60 -0
- package/src/v2/hooks/__tests__/standard-schema-types.test.tsx +152 -0
- package/src/v2/hooks/__tests__/standard-schema.test.tsx +282 -0
- package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +132 -0
- package/src/v2/hooks/__tests__/use-agent-context.test.tsx +401 -0
- package/src/v2/hooks/__tests__/use-agent-error-state.test.tsx +44 -0
- package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +205 -0
- package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +148 -0
- package/src/v2/hooks/__tests__/use-component.test.tsx +123 -0
- package/src/v2/hooks/__tests__/use-configure-suggestions.e2e.test.tsx +696 -0
- package/src/v2/hooks/__tests__/use-default-render-tool.test.tsx +153 -0
- package/src/v2/hooks/__tests__/use-frontend-tool-available.test.tsx +167 -0
- package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +2129 -0
- package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx +1261 -0
- package/src/v2/hooks/__tests__/use-interrupt.test.tsx +397 -0
- package/src/v2/hooks/__tests__/use-katex-styles.test.tsx +56 -0
- package/src/v2/hooks/__tests__/use-keyboard-height.test.tsx +192 -0
- package/src/v2/hooks/__tests__/use-render-tool.test.tsx +259 -0
- package/src/v2/hooks/__tests__/use-suggestions.e2e.test.tsx +524 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +433 -0
- package/src/v2/hooks/__tests__/zod-regression.test.tsx +311 -0
- package/src/v2/hooks/index.ts +18 -0
- package/src/v2/hooks/use-agent-context.tsx +45 -0
- package/src/v2/hooks/use-agent.tsx +155 -0
- package/src/v2/hooks/use-component.tsx +89 -0
- package/src/v2/hooks/use-configure-suggestions.tsx +187 -0
- package/src/v2/hooks/use-default-render-tool.tsx +254 -0
- package/src/v2/hooks/use-frontend-tool.tsx +43 -0
- package/src/v2/hooks/use-human-in-the-loop.tsx +81 -0
- package/src/v2/hooks/use-interrupt.tsx +305 -0
- package/src/v2/hooks/use-keyboard-height.tsx +67 -0
- package/src/v2/hooks/use-render-activity-message.tsx +73 -0
- package/src/v2/hooks/use-render-custom-messages.tsx +93 -0
- package/src/v2/hooks/use-render-tool-call.tsx +175 -0
- package/src/v2/hooks/use-render-tool.tsx +181 -0
- package/src/v2/hooks/use-suggestions.tsx +91 -0
- package/src/v2/hooks/use-threads.tsx +256 -0
- package/src/v2/hooks/useKatexStyles.ts +27 -0
- package/src/v2/index.css +1 -1
- package/src/v2/index.ts +18 -2
- package/src/v2/lib/__tests__/completePartialMarkdown.test.ts +495 -0
- package/src/v2/lib/__tests__/renderSlot.test.tsx +588 -0
- package/src/v2/lib/react-core.ts +156 -0
- package/src/v2/lib/slots.tsx +143 -0
- package/src/v2/lib/transcription-client.ts +184 -0
- package/src/v2/lib/utils.ts +8 -0
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +162 -0
- package/src/v2/providers/CopilotKitProvider.tsx +600 -0
- package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +546 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.license.test.tsx +101 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.onError.test.tsx +69 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.renderCustomMessages.e2e.test.tsx +881 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.stability.test.tsx +740 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +642 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.wildcard.test.tsx +294 -0
- package/src/v2/providers/index.ts +14 -0
- package/src/v2/styles/globals.css +230 -0
- package/src/v2/types/__tests__/defineToolCallRenderer.test.tsx +525 -0
- package/src/v2/types/defineToolCallRenderer.ts +65 -0
- package/src/v2/types/frontend-tool.ts +8 -0
- package/src/v2/types/human-in-the-loop.ts +33 -0
- package/src/v2/types/index.ts +7 -0
- package/src/v2/types/interrupt.ts +15 -0
- package/src/v2/types/react-activity-message-renderer.ts +27 -0
- package/src/v2/types/react-custom-message-renderer.ts +17 -0
- package/src/v2/types/react-tool-call-renderer.ts +32 -0
- package/tsdown.config.ts +34 -10
- package/vitest.config.mjs +4 -3
- package/LICENSE +0 -21
- package/dist/copilotkit-BRPQ2sqS.d.cts +0 -670
- package/dist/copilotkit-BRPQ2sqS.d.cts.map +0 -1
- package/dist/copilotkit-C94ayZbs.cjs +0 -2161
- package/dist/copilotkit-C94ayZbs.cjs.map +0 -1
- package/dist/copilotkit-CwZMFmSK.d.mts +0 -670
- package/dist/copilotkit-CwZMFmSK.d.mts.map +0 -1
- package/dist/copilotkit-Yh_Ld_FX.mjs +0 -2031
- package/dist/copilotkit-Yh_Ld_FX.mjs.map +0 -1
- 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
|
+
});
|