import React from "react"; import { render, screen, act } from "@testing-library/react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { z } from "zod"; import type { AbstractAgent } from "@ag-ui/client"; // Local FlatList that actually invokes renderItem — the shared stub in // src/__mocks__/react-native.ts passes it through as an inert prop, so the // tool-call branch would never execute. Mirrors CopilotChat.test.tsx:64. vi.mock("react-native", async () => { const actual = await vi.importActual("../../__mocks__/react-native"); return { ...actual, FlatList: ({ data, renderItem, ListEmptyComponent, keyExtractor }: any) => { if (!data?.length) return React.createElement( "div", { "data-testid": "flatlist" }, ListEmptyComponent, ); return React.createElement( "div", { "data-testid": "flatlist" }, data.map((item: any, index: number) => React.createElement( "div", { key: keyExtractor?.(item) ?? index }, renderItem({ item, index, separators: {} }), ), ), ); }, }; }); import { CopilotChat } from "../CopilotChat"; import { useRenderTool } from "../../hooks/useRenderTool"; import { TestCopilotKit } from "../../__mocks__/test-copilotkit"; import { assistantToolCall, toolMessage } from "../../__mocks__/tool-fixtures"; // Reports status + args only. Assertions about the RESULT must NOT use this one: // its output cannot distinguish "the correlated result reached the renderer" from // "status flipped for some other reason", which is how a corrupted tool-message // content passed unnoticed. Use `ReportRegistrar` below for those. function Registrar() { useRenderTool({ name: "showPlaces", description: "Show places", parameters: z.object({ title: z.string() }), // Tolerates partial props — that is the contract while streaming. render: ({ args, status }) => (
{`${status}:${(args as { title?: string }).title ?? ""}`}
), }); return null; } // Reports status, args AND result together, so one assertion observes the whole // output of the `toolCallId -> ToolMessage` correlation: the status it derived, // the args it parsed, and the result content it actually handed the renderer. // `undefined` is spelled out rather than stringified, so an absent result stays // distinguishable from an empty-string one. function ReportRegistrar() { useRenderTool({ name: "reportPlaces", description: "Report places", parameters: z.object({ title: z.string() }), render: ({ args, status, result }) => (
{`${status}:${(args as { title?: string }).title ?? ""}|${ result === undefined ? "" : result }`}
), }); return null; } // Renders the raw args object it receives, so tests can assert exactly what the // renderer got for degenerate argument strings (empty / unparseable). function ArgsRegistrar() { useRenderTool({ name: "echoArgs", description: "Echo args", parameters: z.object({}), render: ({ args }) =>
{JSON.stringify(args)}
, }); return null; } // Renders the raw `result` it receives, so tests can assert exactly what the // renderer got for a tool message whose content is not the `string` the // renderer contract declares. function ResultRegistrar() { useRenderTool({ name: "echoResult", description: "Echo result", parameters: z.object({}), render: ({ status, result }) => (
{`${status}|${String(result)}`}
), }); return null; } // All four call the same shared factory, so every fixture here agrees on the // tool-call id the tool results below are correlated against. const assistantWithCall = (args: string) => assistantToolCall("showPlaces", args); const assistantEchoArgs = (args: string) => assistantToolCall("echoArgs", args); const assistantEchoResult = assistantToolCall("echoResult", "{}"); const assistantReportCall = (args: string) => assistantToolCall("reportPlaces", args); // The one fixture here that cannot be the typed `toolMessage()` factory: the // "tool-result content" suite drives content the `ToolMessage` type FORBIDS // (arrays, objects, null, circular) through `toolResultContent`, and no // properly-typed fixture can express that. Its string cases share this helper for // uniformity within that suite; every tool result OUTSIDE it is typed. const toolResult = (content: unknown) => ({ id: "m2", role: "tool", toolCallId: "tc1", content, }); describe("CopilotChat tool-call rendering", () => { it("paints PARTIAL args with status inProgress while the call streams", () => { // '{"title":"Roof' is invalid JSON — the model is mid-write. The old code // JSON.parse'd it, warned, and fell back to {}, so nothing painted until // the call completed. render( , ); expect(screen.getByTestId("places").textContent).toBe("inProgress:Roof"); }); it("reports complete and passes the result through when a tool message exists", () => { render( , ); // Reads the RESULT, not only the status. Asserting `complete` alone is // satisfied by ANY tool message reaching `renderToolCall`, so it stayed // green when the correlated content was replaced with a constant. expect(screen.getByTestId("report").textContent).toBe( "complete:Rooftop|ok", ); }); it("does NOT complete a tool call that no tool message is correlated to", () => { // The other direction of the same map. Every positive fixture in this file // matches on id, so a lookup that ignores the key — keyed on the tool // message's own id, or falling back to "any tool message we have" — reads as // a correct correlation. Here the only tool result belongs to a DIFFERENT // call, so a leak is visible as both a completed status and a stray result. render( , ); expect(screen.getByTestId("report").textContent).toBe( "inProgress:Rooftop|", ); }); it("keeps rendering a tool call after the registering component unmounts", () => { // The chat-history regression: RN's old Map deleted the renderer on unmount, // so navigating away degraded earlier tool calls to the placeholder. Core // deliberately keeps renderer entries. const { rerender } = render( , ); expect(screen.getByTestId("places")).toBeTruthy(); rerender( , ); expect(screen.getByTestId("places")).toBeTruthy(); expect(screen.queryByText("Called: showPlaces")).toBeNull(); }); it("reports executing status when the call id is in executingToolCallIds and has no result", () => { // No tool message + id in executingToolCallIds → core derives "executing" // (executing sits between inProgress and complete in the status ladder). render( , ); expect(screen.getByTestId("places").textContent).toBe("executing:Rooftop"); }); it("yields an empty args object for empty-string arguments without throwing", () => { render( , ); expect(screen.getByTestId("args").textContent).toBe("{}"); }); it("yields an empty args object for unrepairable JSON arguments without throwing", () => { render( , ); expect(screen.getByTestId("args").textContent).toBe("{}"); }); it("falls back to the placeholder for an unregistered tool", () => { render( , ); expect(screen.getByText("Called: notRegistered")).toBeTruthy(); }); }); /** * How PRODUCTION changes `agent.messages`, and why it needs its own suite. * * Core NEVER reassigns `agent.messages`. Tool results are inserted with * `agent.messages.splice(insertAt, 0, toolMessage)` (packages/core, run-handler), * new messages are appended by `AbstractAgent.addMessage` (a `push`), and the * AG-UI apply pipeline mutates the SAME array for the whole run. `useAgent` * then re-renders with a bare `forceUpdate()` — it does not hand down a new * array either. * * The array's IDENTITY therefore stays constant while its CONTENT changes. Any * `useMemo(..., [messages])` in the chat is invalidated by neither, so it * freezes at whatever the first render of the run saw. * * The rest of this file (and every other RN chat suite) drives messages by * re-rendering `TestCopilotKit` with a NEW array, which does change identity — * so those tests pass whether or not the memo dependencies are correct. These * assertions deliberately mutate in place instead, via the same public * `addMessage` that core's own paths bottom out in. */ describe("CopilotChat against in-place message mutation", () => { // The object-content test below spies on console.warn; without this the spy // would outlive it and swallow warnings from later tests in this file. afterEach(() => { vi.restoreAllMocks(); }); it("reports complete and passes the result once a tool result is pushed in place", async () => { // A plain box, not React.createRef — TestCopilotKit only writes to it. const agentRef: React.MutableRefObject = { current: null, }; render( , ); // No tool message yet, and the id is not executing → core derives inProgress, // and the renderer is handed no result at all. expect(screen.getByTestId("report").textContent).toBe( "inProgress:Rooftop|", ); await act(async () => { // A real `ToolMessage`, so `addMessage` needs no cast and the id the // correlation keys on cannot go missing. agentRef.current!.addMessage(toolMessage("tc1", "ok")); }); // The toolCallId → ToolMessage correlation must see the pushed message AND // carry its content through — asserting the status alone would pass even if // the map delivered a different message's result. expect(screen.getByTestId("report").textContent).toBe( "complete:Rooftop|ok", ); }); it("renders a tool call that is pushed in place after mount", async () => { // A plain box, not React.createRef — TestCopilotKit only writes to it. const agentRef: React.MutableRefObject = { current: null, }; render( , ); expect(screen.queryByTestId("places")).toBeNull(); await act(async () => { // Typed `AssistantMessage` from the shared factory — assignable to // `Message` on its own, so the old double cast is gone. agentRef.current!.addMessage(assistantWithCall('{"title":"Rooftop"}')); }); // The flat-list items must see the pushed assistant message. expect(screen.getByTestId("places").textContent).toBe("inProgress:Rooftop"); }); /** * Pins the OBJECT branch of `messagesFingerprint`'s content key. * * A length-based key is a constant 0 for every object, so before that branch * existed any two objects fingerprinted identically: replacing a message's * object content in place left `messagesKey` byte-identical, the * `toolCallId -> ToolMessage` memo never recomputed, and the renderer kept the * result serialized from the FIRST object it saw. * * This asserts through the renderer rather than calling the fingerprint * directly, so it fails for the reason a user would notice (a stale `result`) * instead of merely restating the implementation. * * Re-rendering the provider is safe here, unlike in the `agentRef` tests above: * the mutation is to a message OBJECT inside the array this test owns, and the * SAME array reference is passed again, so the provider's per-render re-seed is * a no-op rather than a replacement. Nothing else in the fingerprint moves — * same ids, roles, `toolCallId` and tool calls — so the object branch is the * only thing that can invalidate the memo. */ it("re-renders a tool result when object content is replaced in place", () => { // Non-string tool content is warned about by design; silence it so this // assertion is about the memo, not about the warning. vi.spyOn(console, "warn").mockImplementation(() => {}); const messages: Array> = [ assistantEchoResult, { id: "m2", role: "tool", toolCallId: "tc1", content: { step: "one" } }, ]; // A FACTORY, not a stored element: re-rendering the identical element // reference lets React bail out of the subtree entirely, which would make // this test pass for the wrong reason (nothing re-rendered at all). const tree = () => ( ); const { rerender } = render(tree()); expect(screen.getByTestId("result").textContent).toBe( 'complete|{"step":"one"}', ); // Same id, same role, same toolCallId — only the object content differs, and // it serializes to the same LENGTH as well, so a key that measured either the // type or the size alone would still miss this. messages[1].content = { step: "two" }; rerender(tree()); expect(screen.getByTestId("result").textContent).toBe( 'complete|{"step":"two"}', ); }); }); describe("CopilotChat tool-result content", () => { afterEach(() => { vi.restoreAllMocks(); }); const renderWithResult = (content: unknown) => render( , ); it("passes a string result through verbatim without warning", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); renderWithResult('{"ok":true}'); expect(screen.getByTestId("result").textContent).toBe( 'complete|{"ok":true}', ); expect(warn).not.toHaveBeenCalled(); }); it("keeps a genuinely EMPTY string result empty and does not warn", () => { // "" is a legitimate tool result. It must stay silent, so a warning is // reserved for content the code could not represent. const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); renderWithResult(""); expect(screen.getByTestId("result").textContent).toBe("complete|"); expect(warn).not.toHaveBeenCalled(); }); it("serialises ARRAY tool content instead of collapsing it to an empty result", () => { // The silent-failure regression: array content (structured / attachment // parts) was coerced to "", which a renderer cannot distinguish from a tool // that genuinely returned nothing. const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); renderWithResult([{ type: "text", text: "hello" }]); expect(screen.getByTestId("result").textContent).toBe( 'complete|[{"type":"text","text":"hello"}]', ); expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0]?.[0])).toContain("tc1"); }); it("serialises OBJECT tool content instead of collapsing it to an empty result", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); renderWithResult({ status: "ok" }); expect(screen.getByTestId("result").textContent).toBe( 'complete|{"status":"ok"}', ); expect(warn).toHaveBeenCalledTimes(1); }); it("warns for NULL tool content even though it has nothing to serialise", () => { // Nothing is lost by rendering "", but the message is still malformed, so // the failure must be audible rather than silent. const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); renderWithResult(null); expect(screen.getByTestId("result").textContent).toBe("complete|"); expect(warn).toHaveBeenCalledTimes(1); }); it("does not throw on tool content that cannot be JSON-serialised", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const circular: Record = {}; circular.self = circular; expect(() => renderWithResult(circular)).not.toThrow(); expect(screen.getByTestId("result").textContent).toBe( "complete|[object Object]", ); expect(warn).toHaveBeenCalledTimes(1); }); });