import React, { useEffect, useState, useReducer } from "react"; import { screen, fireEvent, waitFor } from "@testing-library/react"; import { z } from "zod"; import { useFrontendTool } from "../use-frontend-tool"; import { ReactFrontendTool } from "../../types"; import { CopilotChat } from "../../components/chat/CopilotChat"; import CopilotChatToolCallsView from "../../components/chat/CopilotChatToolCallsView"; import { AssistantMessage, Message } from "@ag-ui/core"; import { ToolCallStatus } from "@copilotkit/core"; import { AbstractAgent, EventType, type AgentSubscriber, type BaseEvent, type RunAgentInput, type RunAgentParameters, } from "@ag-ui/client"; import { Observable } from "rxjs"; import { MockStepwiseAgent, renderWithCopilotKit, runStartedEvent, runFinishedEvent, toolCallChunkEvent, toolCallResultEvent, textChunkEvent, testId, } from "../../__tests__/utils/test-helpers"; describe("useFrontendTool E2E - Dynamic Registration", () => { describe("Minimal dynamic registration without chat run", () => { it("registers tool and renders tool call via ToolCallsView", async () => { // No agent run; we render ToolCallsView directly const DynamicToolComponent: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "dynamicTool", parameters: z.object({ message: z.string() }), render: ({ name, args }) => (
{name}: {args.message}
), }; useFrontendTool(tool); return null; }; const toolCallId = testId("tc_dyn"); const assistantMessage: AssistantMessage = { id: testId("a"), role: "assistant", content: "", toolCalls: [ { id: toolCallId, type: "function", function: { name: "dynamicTool", arguments: JSON.stringify({ message: "hello" }), }, } as any, ], } as any; const messages: Message[] = []; const ui = renderWithCopilotKit({ children: ( <> ), }); await waitFor(() => { const el = screen.getByTestId("dynamic-tool-render"); expect(el).toBeDefined(); expect(el.textContent).toContain("dynamicTool"); expect(el.textContent).toContain("hello"); }); // Explicitly unmount to avoid any lingering handles ui.unmount(); }); }); describe("Register at runtime", () => { it("should register tool dynamically after provider is mounted", async () => { const agent = new MockStepwiseAgent(); // Inner component that uses the hook const ToolUser: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "dynamicTool", parameters: z.object({ message: z.string() }), render: ({ name, args, result }) => (
{name}: {args.message} | Result:{" "} {result ? JSON.stringify(result) : "pending"}
), handler: async (args) => { return { processed: args.message.toUpperCase() }; }, }; useFrontendTool(tool); return null; }; // Component that registers a tool after mount const DynamicToolComponent: React.FC = () => { const [isRegistered, setIsRegistered] = useState(false); useEffect(() => { // Register immediately after mount setIsRegistered(true); }, []); return ( <>
{isRegistered ? "Registered" : "Not registered"}
{isRegistered && } ); }; renderWithCopilotKit({ agent, children: ( <>
), }); // Wait for dynamic registration await waitFor(() => { expect(screen.getByTestId("dynamic-status").textContent).toBe( "Registered", ); }); // Submit a message that will trigger the dynamically registered tool const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Use dynamic tool" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Wait for message to be processed await waitFor(() => { expect(screen.getByText("Use dynamic tool")).toBeDefined(); }); const messageId = testId("msg"); const toolCallId = testId("tc"); // Emit tool call for the dynamically registered tool agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "dynamicTool", parentMessageId: messageId, delta: '{"message":"hello world"}', }), ); // The dynamically registered renderer should appear await waitFor(() => { const toolRender = screen.getByTestId("dynamic-tool-render"); expect(toolRender).toBeDefined(); expect(toolRender.textContent).toContain("hello world"); }); // Send result agent.emit( toolCallResultEvent({ toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ processed: "HELLO WORLD" }), }), ); await waitFor(() => { const toolRender = screen.getByTestId("dynamic-tool-render"); expect(toolRender.textContent).toContain("HELLO WORLD"); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Streaming tool calls with incomplete JSON", () => { it("renders tool calls progressively as incomplete JSON chunks arrive", async () => { const agent = new MockStepwiseAgent(); // Tool that renders the arguments it receives const StreamingTool: React.FC = () => { const tool: ReactFrontendTool<{ name: string; items: string[]; count: number; }> = { name: "streamingTool", parameters: z.object({ name: z.string(), items: z.array(z.string()), count: z.number(), }), render: ({ args }) => (
{args.name || "undefined"}
{args.items ? args.items.join(", ") : "undefined"}
{args.count !== undefined ? args.count : "undefined"}
), }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit a message to start the agent const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test streaming" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Wait for message to appear await waitFor(() => { expect(screen.getByText("Test streaming")).toBeDefined(); }); const messageId = testId("msg"); const toolCallId = testId("tc"); // Start the run agent.emit(runStartedEvent()); // Stream incomplete JSON chunks // First chunk: just opening brace and part of first field agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "streamingTool", parentMessageId: messageId, delta: '{"na', }), ); // Check that tool is rendering (even with incomplete JSON) await waitFor(() => { expect(screen.getByTestId("streaming-tool-render")).toBeDefined(); }); // Second chunk: complete the name field agent.emit( toolCallChunkEvent({ toolCallId, parentMessageId: messageId, delta: 'me":"Test Tool"', }), ); // Check name is now rendered await waitFor(() => { expect(screen.getByTestId("tool-name").textContent).toBe("Test Tool"); }); // Third chunk: start items array agent.emit( toolCallChunkEvent({ toolCallId, parentMessageId: messageId, delta: ',"items":["item1"', }), ); // Check items array has first item await waitFor(() => { expect(screen.getByTestId("tool-items").textContent).toContain("item1"); }); // Fourth chunk: add more items and start count agent.emit( toolCallChunkEvent({ toolCallId, parentMessageId: messageId, delta: ',"item2","item3"],"cou', }), ); // Check items array is complete await waitFor(() => { expect(screen.getByTestId("tool-items").textContent).toBe( "item1, item2, item3", ); }); // Final chunk: complete the JSON agent.emit( toolCallChunkEvent({ toolCallId, parentMessageId: messageId, delta: 'nt":42}', }), ); // Check count is rendered await waitFor(() => { expect(screen.getByTestId("tool-count").textContent).toBe("42"); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Tool followUp property behavior", () => { it("stops agent execution when followUp is false", async () => { const agent = new MockStepwiseAgent(); const NoFollowUpTool: React.FC = () => { const tool: ReactFrontendTool<{ action: string }> = { name: "noFollowUpTool", parameters: z.object({ action: z.string() }), followUp: false, // This should stop execution after tool call render: ({ args, status }) => (
{args.action || "no action"}
{status}
), }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit a message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Execute no followup" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Execute no followup")).toBeDefined(); }); const messageId = testId("msg"); const toolCallId = testId("tc"); // Start run and emit tool call agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "noFollowUpTool", parentMessageId: messageId, delta: '{"action":"stop-after-this"}', }), ); // Tool should render await waitFor(() => { expect(screen.getByTestId("no-followup-tool")).toBeDefined(); expect(screen.getByTestId("tool-action").textContent).toBe( "stop-after-this", ); }); // The agent should NOT continue after this tool call // We can verify this by NOT emitting more events and checking the UI state // In a real scenario, the agent would stop sending events agent.emit(runFinishedEvent()); agent.complete(); // Verify execution stopped (no further messages) // The chat should only have the user message and tool call, no follow-up const messages = screen.queryAllByRole("article"); expect(messages.length).toBeLessThanOrEqual(2); // User message + tool response }); it("continues agent execution when followUp is true or undefined", async () => { const agent = new MockStepwiseAgent(); const ContinueFollowUpTool: React.FC = () => { const tool: ReactFrontendTool<{ action: string }> = { name: "continueFollowUpTool", parameters: z.object({ action: z.string() }), // followUp is undefined (default) - should continue execution render: ({ args }) => (
{args.action || "no action"}
), }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit a message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Execute with followup" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Execute with followup")).toBeDefined(); }); const messageId = testId("msg"); const toolCallId = testId("tc"); const followUpMessageId = testId("followup"); // Start run and emit tool call agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "continueFollowUpTool", parentMessageId: messageId, delta: '{"action":"continue-after-this"}', }), ); // Tool should render await waitFor(() => { expect(screen.getByTestId("continue-followup-tool")).toBeDefined(); expect(screen.getByTestId("tool-action").textContent).toBe( "continue-after-this", ); }); // The agent SHOULD continue after this tool call // Emit a follow-up message to simulate continued execution agent.emit( textChunkEvent( followUpMessageId, "This is a follow-up message after tool execution", ), ); // Verify the follow-up message appears await waitFor(() => { expect( screen.getByText("This is a follow-up message after tool execution"), ).toBeDefined(); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Agent input plumbing", () => { it("forwards registered frontend tools to runAgent input", async () => { class InstrumentedMockAgent extends MockStepwiseAgent { // Shared so the clone and original both see the captured parameters private _capture: { lastRunParameters?: RunAgentParameters } = {}; get lastRunParameters(): RunAgentParameters | undefined { return this._capture.lastRunParameters; } clone(): this { const cloned = super.clone(); (cloned as unknown as InstrumentedMockAgent)._capture = this._capture; return cloned; } async runAgent( parameters?: RunAgentParameters, subscriber?: AgentSubscriber, ) { this._capture.lastRunParameters = parameters; return super.runAgent(parameters, subscriber); } } const agent = new InstrumentedMockAgent(); const ToolRegistrar: React.FC = () => { const tool: ReactFrontendTool<{ query: string }> = { name: "inspectionTool", parameters: z.object({ query: z.string() }), handler: async ({ query }) => `handled ${query}`, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Trigger inspection" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(agent.lastRunParameters).toBeDefined(); }); const messageId = testId("msg"); agent.emit(runStartedEvent()); agent.emit( toolCallResultEvent({ toolCallId: testId("tc"), messageId: `${messageId}_result`, content: JSON.stringify({}), }), ); agent.emit(runFinishedEvent()); agent.complete(); expect(agent.lastRunParameters?.tools).toBeDefined(); }); }); describe("Unmount disables handler, render persists", () => { it("Tool is properly removed from copilotkit.tools after component unmounts", async () => { // A deterministic agent that emits a single tool call per run and finishes class OneShotToolCallAgent extends AbstractAgent { private runCount = 0; clone(): OneShotToolCallAgent { const cloned = new OneShotToolCallAgent(); cloned.agentId = this.agentId; // Share runCount via reference so the second run emits different args Object.defineProperty(cloned, "runCount", { get: () => this.runCount, set: (v: number) => { this.runCount = v; }, }); return cloned; } async detachActiveRun(): Promise {} run(_input: RunAgentInput): Observable { return new Observable((observer) => { const messageId = testId("m"); const toolCallId = testId("tc"); this.runCount += 1; const valueArg = this.runCount === 1 ? "first call" : "second call"; observer.next({ type: EventType.RUN_STARTED } as BaseEvent); observer.next({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "temporaryTool", parentMessageId: messageId, delta: JSON.stringify({ value: valueArg }), } as BaseEvent); observer.next({ type: EventType.RUN_FINISHED } as BaseEvent); observer.complete(); return () => {}; }); } } const agent = new OneShotToolCallAgent(); let handlerCalls = 0; // Component that can be toggled on/off const ToggleableToolComponent: React.FC = () => { const tool: ReactFrontendTool<{ value: string }> = { name: "temporaryTool", parameters: z.object({ value: z.string() }), followUp: false, handler: async ({ value }) => { handlerCalls += 1; return `HANDLED ${value.toUpperCase()}`; }, render: ({ name, args, result, status }) => (
{name}: {args.value} | Status: {status} | Result:{" "} {String(result ?? "")}
), }; useFrontendTool(tool); return
Tool is mounted
; }; const TestWrapper: React.FC = () => { const [showTool, setShowTool] = useState(true); return ( <> {showTool && }
); }; renderWithCopilotKit({ agent, children: }); // Tool should be mounted initially expect(screen.getByTestId("tool-mounted")).toBeDefined(); // Run 1: submit a message to trigger agent run with "first call" const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Trigger 1" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // The tool should render and handler should have produced a result await waitFor(() => { const toolRender = screen.getByTestId("temporary-tool"); expect(toolRender.textContent).toContain("first call"); expect(toolRender.textContent).toContain("HANDLED FIRST CALL"); expect(handlerCalls).toBe(1); }); // Unmount the tool component (removes handler but keeps renderer via hook policy) fireEvent.click(screen.getByTestId("toggle-button")); await waitFor(() => { expect(screen.queryByTestId("tool-mounted")).toBeNull(); }); // Run 2: trigger agent again with "second call" fireEvent.change(input, { target: { value: "Trigger 2" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // The renderer should still render with new args, but no handler result should be produced await waitFor(() => { const toolRender = screen.getAllByTestId("temporary-tool"); // There will be two renders in the chat history; check the last one const last = toolRender[toolRender.length - 1]; expect(last?.textContent).toContain("second call"); // The handler should not have been called a second time since tool was removed expect(handlerCalls).toBe(1); }); }); }); describe("Override behavior", () => { it("should use latest registration when same tool name is registered multiple times", async () => { const agent = new MockStepwiseAgent(); // First component with initial tool definition const FirstToolComponent: React.FC = () => { const tool: ReactFrontendTool<{ text: string }> = { name: "overridableTool", parameters: z.object({ text: z.string() }), render: ({ name, args }) => (
First Version: {args.text} ({name})
), }; useFrontendTool(tool); return null; }; // Second component with override tool definition const SecondToolComponent: React.FC<{ isActive: boolean }> = ({ isActive, }) => { if (!isActive) return null; const tool: ReactFrontendTool<{ text: string }> = { name: "overridableTool", parameters: z.object({ text: z.string() }), render: ({ name, args }) => (
Second Version (Override): {args.text} ({name})
), }; useFrontendTool(tool); return null; }; const TestWrapper: React.FC = () => { const [showSecond, setShowSecond] = useState(false); return ( <>
); }; renderWithCopilotKit({ agent, children: , }); // Submit message before override const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test original" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Wait for message to be processed await waitFor(() => { expect(screen.getByText("Test original")).toBeDefined(); }); const messageId1 = testId("msg1"); const toolCallId1 = testId("tc1"); agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: toolCallId1, toolCallName: "overridableTool", parentMessageId: messageId1, delta: '{"text":"before override"}', }), ); // First version should render await waitFor(() => { const firstVersion = screen.getByTestId("first-version"); expect(firstVersion.textContent).toContain("before override"); }); agent.emit(runFinishedEvent()); // Activate the override const overrideButton = screen.getByTestId("activate-override"); fireEvent.click(overrideButton); // Submit another message after override fireEvent.change(input, { target: { value: "Test override" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Wait for message to be processed await waitFor(() => { expect(screen.getByText("Test override")).toBeDefined(); }); const messageId2 = testId("msg2"); const toolCallId2 = testId("tc2"); agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: toolCallId2, toolCallName: "overridableTool", parentMessageId: messageId2, delta: '{"text":"after override"}', }), ); // Second version should render (override) - there might be multiple due to both tool calls await waitFor(() => { const secondVersions = screen.getAllByTestId("second-version"); // Find the one with "after override" const afterOverride = secondVersions.find((el) => el.textContent?.includes("after override"), ); expect(afterOverride).toBeDefined(); expect(afterOverride?.textContent).toContain("after override"); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Integration with Chat UI", () => { it("should render tool output correctly in chat interface", async () => { const agent = new MockStepwiseAgent(); const IntegratedToolComponent: React.FC = () => { const tool: ReactFrontendTool<{ action: string; target: string }> = { name: "chatIntegratedTool", parameters: z.object({ action: z.string(), target: z.string(), }), render: ({ name, args, result, status }) => (
Tool: {name}
Action: {args.action}
Target: {args.target}
Status: {status}
{result &&
Result: {JSON.stringify(result)}
}
), handler: async (args) => { return { success: true, message: `${args.action} completed on ${args.target}`, }; }, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit user message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Perform an action" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // User message should appear in chat await waitFor(() => { expect(screen.getByText("Perform an action")).toBeDefined(); }); const messageId = testId("msg"); const toolCallId = testId("tc"); // Stream tool call agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "chatIntegratedTool", parentMessageId: messageId, delta: '{"action":"process","target":"data"}', }), ); // Tool should render in chat with proper styling await waitFor(() => { const toolRender = screen.getByTestId("integrated-tool"); expect(toolRender).toBeDefined(); expect(toolRender.textContent).toContain("Action: process"); expect(toolRender.textContent).toContain("Target: data"); expect(toolRender.classList.contains("tool-render")).toBe(true); }); // Send result agent.emit( toolCallResultEvent({ toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ success: true, message: "process completed on data", }), }), ); // Result should appear in the tool render await waitFor(() => { const toolRender = screen.getByTestId("integrated-tool"); expect(toolRender.textContent).toContain("Result:"); expect(toolRender.textContent).toContain("process completed on data"); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Tool Executing State", () => { it("should be in executing state while handler is running", async () => { const statusHistory: ToolCallStatus[] = []; let handlerStarted = false; let handlerCompleted = false; let handlerResult: any = null; // We'll use a custom agent that tracks when tool handlers execute const agent = new MockStepwiseAgent(); const ExecutingStateTool: React.FC = () => { const tool: ReactFrontendTool<{ value: string }> = { name: "executingStateTool", parameters: z.object({ value: z.string() }), render: ({ args, status, result }) => { // Track all status changes useEffect(() => { if (!statusHistory.includes(status)) { statusHistory.push(status); } }, [status]); return (
{status}
{args.value || "undefined"}
{result ? JSON.stringify(result) : "no-result"}
); }, handler: async (args) => { handlerStarted = true; // Simulate async work to allow React to re-render with Executing status await new Promise((resolve) => setTimeout(resolve, 50)); handlerCompleted = true; handlerResult = { processed: args.value.toUpperCase() }; return handlerResult; }, }; useFrontendTool(tool); // No need for subscription here - the hook already subscribes internally return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message to trigger agent.runAgent const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test executing state" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Wait for message to appear await waitFor(() => { expect(screen.getByText("Test executing state")).toBeDefined(); }); // Emit tool call events from the agent const messageId = testId("msg"); const toolCallId = testId("tc"); agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "executingStateTool", parentMessageId: messageId, delta: '{"value":"test"}', }), ); // Wait for tool to render with InProgress status await waitFor(() => { const toolEl = screen.getByTestId("executing-tool"); expect(toolEl).toBeDefined(); expect(screen.getByTestId("tool-value").textContent).toBe("test"); expect(screen.getByTestId("tool-status").textContent).toBe( ToolCallStatus.InProgress, ); }); agent.emit(runFinishedEvent()); // Complete the agent to trigger handler execution agent.complete(); // Trigger another run to process the tool await waitFor( async () => { // The handler should start executing expect(handlerStarted).toBe(true); }, { timeout: 3000 }, ); // Wait for handler to complete await waitFor( () => { expect(handlerCompleted).toBe(true); }, { timeout: 3000 }, ); // Verify the handler executed expect(handlerStarted).toBe(true); expect(handlerCompleted).toBe(true); expect(handlerResult).toEqual({ processed: "TEST" }); // Wait for status to transition to Complete (React re-render cycle) await waitFor( () => { expect(statusHistory).toContain(ToolCallStatus.Complete); }, { timeout: 3000 }, ); // Verify we captured all three states in the correct order expect(statusHistory).toContain(ToolCallStatus.InProgress); expect(statusHistory).toContain(ToolCallStatus.Executing); // Verify the order is correct const inProgressIndex = statusHistory.indexOf(ToolCallStatus.InProgress); const executingIndex = statusHistory.indexOf(ToolCallStatus.Executing); const completeIndex = statusHistory.indexOf(ToolCallStatus.Complete); expect(inProgressIndex).toBeGreaterThanOrEqual(0); expect(executingIndex).toBeGreaterThan(inProgressIndex); expect(completeIndex).toBeGreaterThan(executingIndex); }); }); describe("Agent Scoping", () => { it("supports multiple tools with same name but different agentId", async () => { // Track which handlers are called let defaultAgentHandlerCalled = false; let specificAgentHandlerCalled = false; let wrongAgentHandlerCalled = false; // We'll test with the default agent const agent = new MockStepwiseAgent(); // Tool scoped to "wrongAgent" - should NOT execute const WrongAgentTool: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "testTool", // Same name as other tools parameters: z.object({ message: z.string() }), agentId: "wrongAgent", // Different agent render: ({ args }) => (
Wrong Agent Tool: {args.message}
), handler: async (args) => { wrongAgentHandlerCalled = true; return { result: `Wrong agent processed: ${args.message}` }; }, }; useFrontendTool(tool); return null; }; // Tool scoped to "default" agent - SHOULD execute const DefaultAgentTool: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "testTool", // Same name parameters: z.object({ message: z.string() }), agentId: "default", // Matches our test agent render: ({ args, result }) => (
Default Agent Tool: {args.message} {result && (
{JSON.stringify(result)}
)}
), handler: async (args) => { defaultAgentHandlerCalled = true; return { result: `Default agent processed: ${args.message}` }; }, }; useFrontendTool(tool); return null; }; // Tool scoped to "specificAgent" - should NOT execute const SpecificAgentTool: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "testTool", // Same name again parameters: z.object({ message: z.string() }), agentId: "specificAgent", // Different agent render: ({ args }) => (
Specific Agent Tool: {args.message}
), handler: async (args) => { specificAgentHandlerCalled = true; return { result: `Specific agent processed: ${args.message}` }; }, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message to trigger tools const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test agent scoping" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test agent scoping")).toBeDefined(); }); const messageId = testId("msg"); const toolCallId = testId("tc"); // Call "testTool" - multiple tools have this name but only the one // scoped to "default" agent should execute its handler agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "testTool", parentMessageId: messageId, delta: '{"message":"test message"}', }), ); agent.emit(runFinishedEvent()); // Wait for tool to render - the correct renderer should be used await waitFor(() => { // The default agent tool should render (it's scoped to our agent) const defaultTool = screen.queryByTestId("default-agent-tool"); expect(defaultTool).not.toBeNull(); expect(defaultTool!.textContent).toContain("test message"); }); // Complete the agent to trigger handler execution agent.complete(); // Wait for handler execution await waitFor(() => { // Only the default agent handler should be called expect(defaultAgentHandlerCalled).toBe(true); }); // Log which handlers were called console.log("Handler calls:", { defaultAgent: defaultAgentHandlerCalled, wrongAgent: wrongAgentHandlerCalled, specificAgent: specificAgentHandlerCalled, }); // Verify the correct handler was executed and others weren't expect(defaultAgentHandlerCalled).toBe(true); expect(wrongAgentHandlerCalled).toBe(false); expect(specificAgentHandlerCalled).toBe(false); // Debug: Check what's actually rendered const defaultTool = screen.queryByTestId("default-agent-tool"); const wrongTool = screen.queryByTestId("wrong-agent-tool"); const specificTool = screen.queryByTestId("specific-agent-tool"); console.log("Tools rendered:", { default: defaultTool ? "yes" : "no", wrong: wrongTool ? "yes" : "no", specific: specificTool ? "yes" : "no", }); // Check if result is displayed const resultEl = screen.queryByTestId("default-result"); if (resultEl) { console.log("Result element found:", resultEl.textContent); } else { console.log("No result element found"); } // The test reveals whether agent scoping works correctly // If the wrong tool's handler is called, this is a bug in core }); it("demonstrates that agent scoping prevents execution of tools for wrong agents", async () => { // This simpler test shows that agent scoping does work for preventing execution let scopedHandlerCalled = false; let globalHandlerCalled = false; const agent = new MockStepwiseAgent(); // Tool scoped to a different agent - should NOT execute const ScopedTool: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "scopedTool", parameters: z.object({ message: z.string() }), agentId: "differentAgent", // Different from default render: ({ args, result }) => (
Scoped Tool: {args.message} {result && (
{JSON.stringify(result)}
)}
), handler: async (args) => { scopedHandlerCalled = true; return { result: `Scoped processed: ${args.message}` }; }, }; useFrontendTool(tool); return null; }; // Global tool (no agentId) - SHOULD execute for any agent const GlobalTool: React.FC = () => { const tool: ReactFrontendTool<{ message: string }> = { name: "globalTool", parameters: z.object({ message: z.string() }), // No agentId - available to all agents render: ({ args, result }) => (
Global Tool: {args.message} {result && (
{JSON.stringify(result)}
)}
), handler: async (args) => { globalHandlerCalled = true; return { result: `Global processed: ${args.message}` }; }, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test scoping" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test scoping")).toBeDefined(); }); const messageId = testId("msg"); // Try to call the scoped tool - handler should NOT execute agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: testId("tc1"), toolCallName: "scopedTool", parentMessageId: messageId, delta: '{"message":"trying scoped"}', }), ); // Tool should render (renderer is always shown) await waitFor(() => { expect(screen.getByTestId("scoped-tool")).toBeDefined(); }); // Call the global tool - handler SHOULD execute agent.emit( toolCallChunkEvent({ toolCallId: testId("tc2"), toolCallName: "globalTool", parentMessageId: messageId, delta: '{"message":"trying global"}', }), ); await waitFor(() => { expect(screen.getByTestId("global-tool")).toBeDefined(); }); agent.emit(runFinishedEvent()); agent.complete(); // Wait for the global handler to be called await waitFor(() => { expect(globalHandlerCalled).toBe(true); }); // Verify that only the global handler was called expect(scopedHandlerCalled).toBe(false); // Should NOT be called (wrong agent) expect(globalHandlerCalled).toBe(true); // Should be called (no agent restriction) // The scoped tool should render but have no result const scopedResult = screen.queryByTestId("scoped-result"); expect(scopedResult).toBeNull(); // The global tool should have a result await waitFor(() => { const globalResult = screen.getByTestId("global-result"); expect(globalResult.textContent).toContain( "Global processed: trying global", ); }); }); }); describe("Nested Tool Calls", () => { it("should enable tool calls that render other tools", async () => { const agent = new MockStepwiseAgent(); let childToolRegistered = false; // Simple approach: both tools registered at top level // but one triggers the other through tool calls const ChildTool: React.FC = () => { const tool: ReactFrontendTool<{ childValue: string }> = { name: "childTool", parameters: z.object({ childValue: z.string() }), render: ({ args }) => (
Child: {args.childValue}
), }; useFrontendTool(tool); useEffect(() => { childToolRegistered = true; }, []); return null; }; const ParentTool: React.FC = () => { const tool: ReactFrontendTool<{ parentValue: string }> = { name: "parentTool", parameters: z.object({ parentValue: z.string() }), render: ({ args }) => (
Parent: {args.parentValue}
), }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Verify both tools are registered expect(childToolRegistered).toBe(true); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test nested tools" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test nested tools")).toBeDefined(); }); const messageId = testId("msg"); // Call parent tool agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: testId("parent-tc"), toolCallName: "parentTool", parentMessageId: messageId, delta: '{"parentValue":"test parent"}', }), ); // Parent tool should render await waitFor(() => { expect(screen.getByTestId("parent-tool")).toBeDefined(); }); // Now call the child tool (simulating nested call) agent.emit( toolCallChunkEvent({ toolCallId: testId("child-tc"), toolCallName: "childTool", parentMessageId: messageId, delta: '{"childValue":"test child"}', }), ); // Child tool should render await waitFor(() => { expect(screen.getByTestId("child-tool")).toBeDefined(); expect(screen.getByTestId("child-tool").textContent).toContain( "test child", ); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Tool Availability", () => { it("should ensure tools are available when request is made", async () => { const agent = new MockStepwiseAgent(); const AvailabilityTestTool: React.FC<{ onRegistered?: () => void }> = ({ onRegistered, }) => { const tool: ReactFrontendTool<{ test: string }> = { name: "availabilityTool", parameters: z.object({ test: z.string() }), render: ({ args }) => (
{args.test}
), handler: async (args) => ({ received: args.test }), }; useFrontendTool(tool); // Notify when registered useEffect(() => { onRegistered?.(); }, [onRegistered]); return null; }; let toolRegistered = false; const onRegistered = () => { toolRegistered = true; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Tool should be available immediately after mounting await waitFor(() => { expect(toolRegistered).toBe(true); }); // Verify tool is in copilotkit.tools // Note: We can't directly access copilotkit.tools from here, // but we can verify it works by calling it const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test availability" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test availability")).toBeDefined(); }); // Tool call should work immediately agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: testId("tc"), toolCallName: "availabilityTool", parentMessageId: testId("msg"), delta: '{"test":"available"}', }), ); // Tool should render successfully await waitFor(() => { expect(screen.getByTestId("availability-tool")).toBeDefined(); expect(screen.getByTestId("availability-tool").textContent).toBe( "available", ); }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Re-render Idempotence", () => { it("should not create duplicates on re-render", async () => { const agent = new MockStepwiseAgent(); let renderCount = 0; const IdempotentTool: React.FC = () => { // Use state to trigger re-renders const [counter, setCounter] = useState(0); const tool: ReactFrontendTool<{ value: string }> = { name: "idempotentTool", parameters: z.object({ value: z.string() }), render: ({ args }) => { renderCount++; return (
Value: {args.value} | Renders: {renderCount}
); }, }; useFrontendTool(tool); return (
); }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test idempotence" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test idempotence")).toBeDefined(); }); // Emit tool call agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: testId("tc"), toolCallName: "idempotentTool", parentMessageId: testId("msg"), delta: '{"value":"test"}', }), ); // Tool should render once await waitFor(() => { const tools = screen.getAllByTestId("idempotent-tool"); expect(tools).toHaveLength(1); expect(tools[0]?.textContent).toContain("Value: test"); }); const initialRenderCount = renderCount; // Trigger re-render by clicking button fireEvent.click(screen.getByTestId("rerender-button")); // Wait for re-render await waitFor(() => { const button = screen.getByTestId("rerender-button"); expect(button.textContent).toContain("1"); }); // Tool should still render only once (no duplicate elements) const toolsAfterRerender = screen.getAllByTestId("idempotent-tool"); expect(toolsAfterRerender).toHaveLength(1); // The render count should not have increased dramatically // (may increase slightly due to React re-renders, but not duplicate the tool) expect(renderCount).toBeLessThanOrEqual(initialRenderCount + 2); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("useFrontendTool dependencies", () => { it("updates tool renderer when optional deps change", async () => { const DependencyDrivenTool: React.FC = () => { const [version, setVersion] = useState(0); const tool: ReactFrontendTool<{ message: string }> = { name: "dependencyTool", parameters: z.object({ message: z.string() }), render: ({ args }) => (
{args.message} (v{version})
), }; useFrontendTool(tool, [version]); const toolCallId = testId("dep_tc"); const assistantMessage: AssistantMessage = { id: testId("dep_a"), role: "assistant", content: "", toolCalls: [ { id: toolCallId, type: "function", function: { name: "dependencyTool", arguments: JSON.stringify({ message: "hello" }), }, } as any, ], } as any; const messages: Message[] = []; return ( <> ); }; renderWithCopilotKit({ children: , }); await waitFor(() => { const el = screen.getByTestId("dependency-tool-render"); expect(el).toBeDefined(); expect(el.textContent).toContain("hello"); expect(el.textContent).toContain("(v0)"); }); fireEvent.click(screen.getByTestId("bump-version")); await waitFor(() => { const el = screen.getByTestId("dependency-tool-render"); expect(el.textContent).toContain("(v1)"); }); }); }); describe("Error Propagation", () => { it("should propagate handler errors to renderer", async () => { const agent = new MockStepwiseAgent(); let handlerCalled = false; let errorThrown = false; const ErrorTool: React.FC = () => { const tool: ReactFrontendTool<{ shouldError: boolean; message: string; }> = { name: "errorTool", parameters: z.object({ shouldError: z.boolean(), message: z.string(), }), render: ({ args, status, result }) => (
{status}
{args.message}
{result ? String(result) : "no-result"}
), handler: async (args) => { handlerCalled = true; if (args.shouldError) { errorThrown = true; throw new Error(`Handler error: ${args.message}`); } return { success: true, message: args.message }; }, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test error" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test error")).toBeDefined(); }); // Emit tool call that will error const messageId = testId("msg"); const toolCallId = testId("tc"); agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "errorTool", parentMessageId: messageId, delta: '{"shouldError":true,"message":"test error"}', }), ); agent.emit(runFinishedEvent()); // Wait for tool to render await waitFor(() => { expect(screen.getByTestId("error-tool")).toBeDefined(); }); // Complete the agent to trigger handler execution agent.complete(); // Wait for handler to be called and error to be thrown await waitFor(() => { expect(handlerCalled).toBe(true); expect(errorThrown).toBe(true); }); // Wait for the error result to be displayed in the renderer await waitFor(() => { const resultEl = screen.getByTestId("error-result"); const resultText = resultEl.textContent || ""; expect(resultText).not.toBe("no-result"); expect(resultText).toContain("Error:"); expect(resultText).toContain("Handler error: test error"); }); // Status should be complete even with error expect(screen.getByTestId("error-status").textContent).toBe( ToolCallStatus.Complete, ); }); it("should handle async errors in handler", async () => { const agent = new MockStepwiseAgent(); const AsyncErrorTool: React.FC = () => { const tool: ReactFrontendTool<{ delay: number; errorMessage: string }> = { name: "asyncErrorTool", parameters: z.object({ delay: z.number(), errorMessage: z.string(), }), render: ({ args, status, result }) => (
{status}
Delay: {args.delay}ms
{args.errorMessage}
{result &&
{result}
}
), handler: async (args) => { // Simulate async operation await new Promise((resolve) => setTimeout(resolve, args.delay)); // In test environment, throwing might not propagate as expected throw new Error(args.errorMessage); }, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test async error" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test async error")).toBeDefined(); }); // Emit tool call that will error after delay agent.emit(runStartedEvent()); agent.emit( toolCallChunkEvent({ toolCallId: testId("tc"), toolCallName: "asyncErrorTool", parentMessageId: testId("msg"), delta: '{"delay":10,"errorMessage":"Async operation failed after delay"}', }), ); // Tool should render immediately with args await waitFor(() => { expect(screen.getByTestId("async-error-tool")).toBeDefined(); expect(screen.getByTestId("async-delay").textContent).toContain("10ms"); expect(screen.getByTestId("async-error-msg").textContent).toContain( "Async operation failed", ); }); // The test verifies that: // 1. Async tools with delays can render immediately // 2. Error messages are properly passed through args // 3. The tool continues to function even with async handlers that may throw // In production, the error would be caught and sent as a result // but in test environment, handler execution may not complete agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Wildcard Handler", () => { it("should handle unknown tools with wildcard", async () => { const agent = new MockStepwiseAgent(); const wildcardHandlerCalls: { name: string; args: any }[] = []; // Note: Wildcard tools work as fallback renderers when no specific tool is found // The wildcard renderer receives the original tool name and arguments const WildcardTool: React.FC = () => { const tool: ReactFrontendTool = { name: "*", parameters: z.any(), render: ({ name, args, status, result }) => (
Wildcard caught: {name}
Args: {JSON.stringify(args)}
Status: {status}
{result && (
Result: {result}
)}
), handler: async (args: any) => { // Track handler calls wildcardHandlerCalls.push({ name: "wildcard", args }); return { handled: "by wildcard", receivedArgs: args }; }, }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test wildcard" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test wildcard")).toBeDefined(); }); agent.emit(runStartedEvent()); // Test 1: Call first undefined tool agent.emit( toolCallChunkEvent({ toolCallId: testId("tc1"), toolCallName: "undefinedTool", parentMessageId: testId("msg"), delta: '{"someParam":"value","anotherParam":123}', }), ); // Wildcard should render the unknown tool with correct name and args await waitFor(() => { const nameEl = screen.getByTestId("wildcard-tool-name"); expect(nameEl.textContent).toContain("undefinedTool"); const argsEl = screen.getByTestId("wildcard-args"); expect(argsEl.textContent).toContain("someParam"); expect(argsEl.textContent).toContain("value"); expect(argsEl.textContent).toContain("123"); }); // Check status is InProgress or Complete await waitFor(() => { const statusEl = screen.getByTestId("wildcard-status"); expect(statusEl.textContent).toMatch(/Status: (inProgress|complete)/); }); // Test 2: Call another undefined tool to verify wildcard catches multiple agent.emit( toolCallChunkEvent({ toolCallId: testId("tc2"), toolCallName: "anotherUnknownTool", parentMessageId: testId("msg"), delta: '{"differentArg":"test"}', }), ); // Should render both unknown tools await waitFor(() => { const tool1 = screen.getByTestId("wildcard-render-undefinedTool"); const tool2 = screen.getByTestId("wildcard-render-anotherUnknownTool"); expect(tool1).toBeDefined(); expect(tool2).toBeDefined(); }); // Send result for first tool agent.emit( toolCallResultEvent({ toolCallId: testId("tc1"), messageId: testId("msg_result"), content: "Tool executed successfully", }), ); // Check result is displayed await waitFor(() => { const resultEl = screen.queryByTestId("wildcard-result"); if (resultEl) { expect(resultEl.textContent).toContain("Tool executed successfully"); } }); agent.emit(runFinishedEvent()); agent.complete(); }); }); describe("Renderer Precedence", () => { it("should use specific renderer over wildcard", async () => { const agent = new MockStepwiseAgent(); // Specific tool const SpecificTool: React.FC = () => { const tool: ReactFrontendTool<{ value: string }> = { name: "specificTool", parameters: z.object({ value: z.string() }), render: ({ args }) => (
Specific: {args.value}
), }; useFrontendTool(tool); return null; }; // Wildcard tool - should only catch unknown tools const WildcardTool: React.FC = () => { const tool: ReactFrontendTool = { name: "*", parameters: z.any(), render: ({ name }) => (
Wildcard: {name}
), }; useFrontendTool(tool); return null; }; renderWithCopilotKit({ agent, children: ( <>
), }); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test precedence" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Test precedence")).toBeDefined(); }); agent.emit(runStartedEvent()); // Call specific tool - should use specific renderer agent.emit( toolCallChunkEvent({ toolCallId: testId("tc1"), toolCallName: "specificTool", parentMessageId: testId("msg"), delta: '{"value":"test specific"}', }), ); // Should render with specific renderer, not wildcard await waitFor(() => { expect(screen.getByTestId("specific-render")).toBeDefined(); expect(screen.getByTestId("specific-render").textContent).toContain( "test specific", ); }); // Call unknown tool - should use wildcard renderer agent.emit( toolCallChunkEvent({ toolCallId: testId("tc2"), toolCallName: "unknownTool", parentMessageId: testId("msg"), delta: '{"someArg":"test wildcard"}', }), ); // Should render with wildcard renderer await waitFor(() => { const wildcards = screen.getAllByTestId("wildcard-render"); expect(wildcards.length).toBeGreaterThan(0); const unknownToolRender = wildcards.find((el) => el.textContent?.includes("unknownTool"), ); expect(unknownToolRender).toBeDefined(); }); // Verify specific tool still used its renderer (not replaced by wildcard) expect(screen.getByTestId("specific-render")).toBeDefined(); agent.emit(runFinishedEvent()); agent.complete(); }); }); });