import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { z } from "zod"; import { CopilotKitProvider, useCopilotKit, } from "../../../providers/CopilotKitProvider"; import { CopilotChat } from "../CopilotChat"; import { AbstractAgent, EventType, type BaseEvent, type RunAgentInput, } from "@ag-ui/client"; import { Observable, Subject } from "rxjs"; import { defineToolCallRenderer, ReactToolCallRenderer, ReactFrontendTool, } from "../../../types"; import CopilotChatToolCallsView from "../CopilotChatToolCallsView"; import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider"; import { AssistantMessage, Message, ToolMessage } from "@ag-ui/core"; import { ToolCallStatus } from "@copilotkit/core"; import { useFrontendTool } from "../../../hooks/use-frontend-tool"; // A minimal mock agent that streams a tool call and a result class MockStreamingAgent extends AbstractAgent { clone(): MockStreamingAgent { return new MockStreamingAgent(); } async detachActiveRun(): Promise {} run(_input: RunAgentInput): Observable { return new Observable((observer) => { const messageId = `m_${Date.now()}`; const toolCallId = `tc_${Date.now()}`; // Start run observer.next({ type: EventType.RUN_STARTED } as BaseEvent); // Stream assistant text chunks observer.next({ type: EventType.TEXT_MESSAGE_CHUNK, messageId, delta: "I will check the weather.", } as BaseEvent); // Start tool call (first chunk contains name + first args) observer.next({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "getWeather", parentMessageId: messageId, delta: '{"location":"Paris","unit":"c', } as BaseEvent); // Continue tool call args observer.next({ type: EventType.TOOL_CALL_CHUNK, toolCallId, parentMessageId: messageId, delta: 'elsius"}', } as BaseEvent); // Tool result observer.next({ type: EventType.TOOL_CALL_RESULT, toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ temperature: 21, unit: "celsius" }), } as BaseEvent); // Finish run observer.next({ type: EventType.RUN_FINISHED } as BaseEvent); observer.complete(); return () => {}; }); } } describe("CopilotChat tool rendering with mock agent", () => { function renderWithProvider() { const agents = { default: new MockStreamingAgent() }; const renderToolCalls = [ defineToolCallRenderer({ name: "getWeather", args: z.object({ location: z.string(), unit: z.string(), }), render: ({ name, args, result }) => (
Tool: {name} | args: {args.location}-{args.unit} | result:{" "} {String(result ?? "")}
), }), ] as unknown as ReactToolCallRenderer[]; return render(
, ); } it("renders the tool component when the agent emits a tool call and result", async () => { renderWithProvider(); // Type a message and submit const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "What is the weather?" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Assert that our tool render appears with the expected test id const tool = await screen.findByTestId("weather-result"); expect(tool).toBeDefined(); // Optionally, ensure result content shows up (from our mock agent) await waitFor(() => { expect(tool.textContent).toMatch(/temperature/); expect(tool.textContent).toMatch(/celsius/); }); }); }); describe("Tool render status narrowing", () => { function renderStatusWithProvider({ isRunning, withResult, }: { isRunning: boolean; withResult: boolean; }) { const renderToolCalls = [ defineToolCallRenderer({ name: "getWeather", args: z.object({ city: z.string().optional() }), render: ({ status, args, result }) => { if (status === ToolCallStatus.InProgress) { return (
INPROGRESS {String(args.city ?? "")}
); } if (status === ToolCallStatus.Executing) { return
EXECUTING {args.city}
; } // ToolCallStatus.Complete return (
COMPLETE {args.city} {String(result ?? "")}
); }, }), ] as unknown as ReactToolCallRenderer[]; const toolCallId = "tc_status_1"; const assistantMessage: AssistantMessage = { id: "a1", role: "assistant", content: "", toolCalls: [ { id: toolCallId, type: "function", function: { name: "getWeather", arguments: '{"city":"Berlin"}' }, } as any, ], } as AssistantMessage; const messages: Message[] = []; if (withResult) { messages.push({ id: "t1", role: "tool", toolCallId, content: "Sunny", } as ToolMessage as any); } return render( , ); } it("renders InProgress when running and no result", async () => { renderStatusWithProvider({ isRunning: true, withResult: false }); const el = await screen.findByTestId("status"); expect(el.textContent).toMatch(/INPROGRESS/); expect(el.textContent).toMatch(/Berlin/); }); it("renders Complete with result when tool message exists", async () => { renderStatusWithProvider({ isRunning: false, withResult: true }); const el = await screen.findByTestId("status"); expect(el.textContent).toMatch(/COMPLETE/); expect(el.textContent).toMatch(/Berlin/); expect(el.textContent).toMatch(/Sunny/); }); it("renders InProgress when not running and no tool result", async () => { renderStatusWithProvider({ isRunning: false, withResult: false }); const el = await screen.findByTestId("status"); expect(el.textContent).toMatch(/INPROGRESS/); expect(el.textContent).toMatch(/Berlin/); }); }); // A controllable streaming agent to step through events deterministically class MockStepwiseAgent extends AbstractAgent { private subject = new Subject(); emit(event: BaseEvent) { if (event.type === EventType.RUN_STARTED) { this.isRunning = true; } else if ( event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR ) { this.isRunning = false; } this.subject.next(event); } complete() { this.isRunning = false; this.subject.complete(); } clone(): MockStepwiseAgent { const cloned = new MockStepwiseAgent(); cloned.agentId = this.agentId; (cloned as unknown as { subject: Subject }).subject = this.subject; return cloned; } async detachActiveRun(): Promise {} run(_input: RunAgentInput): Observable { return this.subject.asObservable(); } } describe("Streaming in-progress without timers", () => { it("shows InProgress for partial args and Complete after result", async () => { const agent = new MockStepwiseAgent(); const renderToolCalls = [ defineToolCallRenderer({ name: "getWeather", args: z.object({ location: z.string(), unit: z.string(), }), render: ({ name, status, args, result }) => (
{name}{" "} {status === ToolCallStatus.InProgress ? "INPROGRESS" : "COMPLETE"}{" "} {String(args.location ?? "")} - {String(args.unit ?? "")}{" "} {String(result ?? "")}
), }), ] as unknown as ReactToolCallRenderer[]; render(
, ); // Submit a user message to trigger runAgent const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Weather please" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Allow React to process the state update await waitFor(() => { expect(screen.getByText("Weather please")).toBeDefined(); }); const messageId = "m_step"; const toolCallId = "tc_step"; // Begin run and stream partial tool-call args agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); agent.emit({ type: EventType.TEXT_MESSAGE_CHUNK, messageId, delta: "Checking weather", } as BaseEvent); // First emit just the tool call start with partial args agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "getWeather", parentMessageId: messageId, delta: '{"location":"Paris"', } as BaseEvent); // Wait for the tool status element to show partial args await waitFor(async () => { const el = await screen.findByTestId("tool-status"); expect(el.textContent).toContain("getWeather INPROGRESS"); expect(el.textContent).toContain("Paris"); }); // Continue streaming more partial data agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, parentMessageId: messageId, delta: ',"unit":"celsius"}', } as BaseEvent); // Wait for the tool status element and check it shows complete args but no result yet await waitFor( async () => { const el = await screen.findByTestId("tool-status"); expect(el.textContent).toContain("getWeather"); expect(el.textContent).toContain("Paris"); expect(el.textContent).toContain("celsius"); // Since we haven't sent a result yet, it should be INPROGRESS expect(el.textContent).toMatch(/INPROGRESS/); }, { timeout: 3000 }, ); // Now send the tool result agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ temperature: 21, unit: "celsius" }), } as BaseEvent); // Check result appears and status changes to COMPLETE await waitFor(async () => { const el = await screen.findByTestId("tool-status"); expect(el.textContent).toMatch(/COMPLETE/); expect(el.textContent).toContain("temperature"); expect(el.textContent).toContain("21"); }); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); }); describe("Executing State Transitions", () => { it("should show Executing status while tool handler is running", async () => { const agent = new MockStepwiseAgent(); let resolveHandler: (() => void) | undefined; const ToolWithDeferredHandler: React.FC = () => { const tool: ReactFrontendTool<{ value: string }> = { name: "slowTool", parameters: z.object({ value: z.string() }), handler: async () => new Promise((resolve) => { resolveHandler = () => resolve({ result: "done" }); }), render: ({ name, status, args, result }) => (
Tool: {name} | Status: {status} | Value: {args.value} | Result:{" "} {result ? "Complete" : "Pending"}
), }; useFrontendTool(tool); return null; }; render(
, ); const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Run slow tool" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Run slow tool")).toBeDefined(); }); const messageId = "m_exec"; const toolCallId = "tc_exec"; agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "slowTool", parentMessageId: messageId, delta: '{"value":"test"}', } as BaseEvent); await waitFor(() => { const status = screen.getByTestId("slow-tool-status"); expect(status.textContent).toMatch(/Status: inProgress/i); expect(status.textContent).toMatch(/Value: test/); }); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); await waitFor(() => { const status = screen.getByTestId("slow-tool-status"); expect(status.textContent).toMatch(/Status: executing/i); expect(resolveHandler).toBeTruthy(); }); if (resolveHandler) { resolveHandler(); } await waitFor(() => { const status = screen.getByTestId("slow-tool-status"); expect(status.textContent).toMatch(/Status: complete/i); expect(status.textContent).toMatch(/Result: Complete/); }); }); }); describe("Multiple Tool Calls in Same Message", () => { it("should render multiple tools independently with their own status", async () => { const agent = new MockStepwiseAgent(); const renderToolCalls = [ defineToolCallRenderer({ name: "tool1", args: z.object({ id: z.string() }), render: ({ status, args, result }) => (
Tool1[{args.id}]: {status} -{" "} {result ? JSON.stringify(result) : "waiting"}
), }), defineToolCallRenderer({ name: "tool2", args: z.object({ id: z.string() }), render: ({ status, args, result }) => (
Tool2[{args.id}]: {status} -{" "} {result ? JSON.stringify(result) : "waiting"}
), }), ] as unknown as ReactToolCallRenderer[]; render(
, ); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Multiple tools" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Allow React to process the state update await waitFor(() => { expect(screen.getByText("Multiple tools")).toBeDefined(); }); const messageId = "m_multi"; const toolCallId1 = "tc_1"; const toolCallId2 = "tc_2"; const toolCallId3 = "tc_3"; agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Stream three tool calls (2 of tool1, 1 of tool2) agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId: toolCallId1, toolCallName: "tool1", parentMessageId: messageId, delta: '{"id":"first"}', } as BaseEvent); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId: toolCallId2, toolCallName: "tool2", parentMessageId: messageId, delta: '{"id":"second"}', } as BaseEvent); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId: toolCallId3, toolCallName: "tool1", parentMessageId: messageId, delta: '{"id":"third"}', } as BaseEvent); // All three should render await waitFor(() => { expect(screen.getByTestId("tool1-first")).toBeDefined(); expect(screen.getByTestId("tool2-second")).toBeDefined(); expect(screen.getByTestId("tool1-third")).toBeDefined(); }); // Send results in different order agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId: toolCallId2, messageId: `${messageId}_r2`, content: JSON.stringify({ result: "B" }), } as BaseEvent); await waitFor(() => { const tool2 = screen.getByTestId("tool2-second"); expect(tool2.textContent).toContain("B"); }); agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId: toolCallId1, messageId: `${messageId}_r1`, content: JSON.stringify({ result: "A" }), } as BaseEvent); agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId: toolCallId3, messageId: `${messageId}_r3`, content: JSON.stringify({ result: "C" }), } as BaseEvent); // All results should be visible await waitFor(() => { expect(screen.getByTestId("tool1-first").textContent).toContain("A"); expect(screen.getByTestId("tool2-second").textContent).toContain("B"); expect(screen.getByTestId("tool1-third").textContent).toContain("C"); }); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); }); describe("Partial Args Accumulation", () => { it("should properly show InProgress status with accumulating partial args", async () => { const agent = new MockStepwiseAgent(); const renderToolCalls = [ defineToolCallRenderer({ name: "complexTool", args: z.object({ name: z.string().optional(), age: z.number().optional(), city: z.string().optional(), }), render: ({ status, args }) => (
Status: {status}
Name: {args.name || "pending"}
Age: {args.age !== undefined ? args.age : "pending"}
City: {args.city || "pending"}
), }), ] as unknown as ReactToolCallRenderer[]; render(
, ); // Submit message const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Complex tool test" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Allow React to process the state update await waitFor(() => { expect(screen.getByText("Complex tool test")).toBeDefined(); }); const messageId = "m_partial"; const toolCallId = "tc_partial"; agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Stream args piece by piece agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "complexTool", parentMessageId: messageId, delta: '{"name":"', } as BaseEvent); // Let React update with the partial data await waitFor(() => { const tool = screen.queryByTestId("complex-tool"); expect(tool).toBeDefined(); }); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, parentMessageId: messageId, delta: 'Alice"', } as BaseEvent); await waitFor(() => { const tool = screen.getByTestId("complex-tool"); expect(tool.textContent).toContain("Name: Alice"); expect(tool.textContent).toContain("Age: pending"); }); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, parentMessageId: messageId, delta: ',"age":30', } as BaseEvent); await waitFor(() => { const tool = screen.getByTestId("complex-tool"); expect(tool.textContent).toContain("Age: 30"); expect(tool.textContent).toContain("City: pending"); }); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, parentMessageId: messageId, delta: ',"city":"Paris"}', } as BaseEvent); await waitFor(() => { const tool = screen.getByTestId("complex-tool"); expect(tool.textContent).toContain("City: Paris"); // All args complete but no result yet - status shows inProgress until result is received expect(tool.textContent).toMatch(/Status: (complete|inProgress)/i); }); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); }); describe("Status Persistence After Agent Stops", () => { it("should remain in InProgress status after agent stops if no result", async () => { const agent = new MockStepwiseAgent(); const renderToolCalls = [ defineToolCallRenderer({ name: "testTool", args: z.object({ value: z.string() }), render: ({ args, status }) => (
{status} {args.value}
), }), ] as unknown as ReactToolCallRenderer[]; render( , ); // Submit message to trigger tool call const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Test message" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); // Wait for user message to appear await waitFor(() => { expect(screen.getByText("Test message")).toBeDefined(); }); const messageId = "msg_status"; const toolCallId = "tc_status"; // Start run and emit tool call agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "testTool", parentMessageId: messageId, delta: '{"value":"test"}', } as BaseEvent); // Tool should be in InProgress while agent is running await waitFor(() => { const statusElement = screen.getByTestId("status"); expect(statusElement.textContent).toBe("inProgress"); }); // Finish the run without providing a tool result agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); // Important: tool should REMAIN in InProgress status, not Complete // Verify status remains inProgress (not changing to complete) await waitFor(() => { const statusElement = screen.getByTestId("status"); expect(statusElement.textContent).toBe("inProgress"); }); const statusElement = screen.getByTestId("status"); expect(statusElement.textContent).toBe("inProgress"); expect(statusElement.textContent).not.toBe("complete"); // To provide result after run finished, we need to start a new run agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Now provide the tool result agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ result: "Tool execution completed" }), } as BaseEvent); // NOW it should be complete await waitFor(() => { const statusEl = screen.getByTestId("status"); expect(statusEl.textContent).toBe("complete"); }); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); });