import React from "react"; import { act, render, waitFor } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import { useRenderTool } from "../useRenderTool"; import { useCopilotKit, useRenderToolCall } from "../../headless"; import type { CopilotKitContextValue } from "../../headless"; import { TestCopilotKit } from "../../__mocks__/test-copilotkit"; /** * `useRenderTool` is a THIN FORWARDER: every field it accepts is handed to * react-core's `useFrontendTool`, and every behaviour it claims is really that * hook's behaviour. So this suite must not mock `useFrontendTool` — an earlier * version did, and the double it substituted only modelled `name`/`render`. * Deleting the `deps`, `handler` and `agentId` forwarding from the hook left * that suite fully green: it could not detect a regression in any of the three * things the hook exists to forward. * * Everything here therefore drives a REAL `CopilotKitCoreReact` through * `TestCopilotKit` (as the sibling `render-tool-call.integration.test.tsx` * does) and asserts on core's own observable behaviour — what `runTool` * executes, what `getTool` resolves, what actually paints — rather than on a * mock's call arguments. */ type Core = CopilotKitContextValue["copilotkit"]; /** Publishes the live core instance so a test can drive it directly. */ function CaptureCore({ into, }: { into: { current: Core | null }; }): React.ReactElement | null { const { copilotkit } = useCopilotKit(); into.current = copilotkit; return null; } // ─── Registration basics ────────────────────────────────────────────────────── describe("useRenderTool", () => { it("registers the tool with react-core, name/description/schema intact", () => { const coreRef: { current: Core | null } = { current: null }; const schema = z.object({ query: z.string() }); function Probe() { useRenderTool({ name: "searchLedger", description: "Search the ledger", parameters: schema, render: () => null, }); return null; } render( , ); const tool = coreRef.current!.getTool({ toolName: "searchLedger" }); expect(tool).toBeDefined(); expect(tool!.description).toBe("Search the ledger"); expect(tool!.parameters).toBe(schema); }); }); describe("useRenderTool registry target", () => { it("registers its renderer into core's renderToolCalls, not a local map", () => { // The whole point of the convergence: one registry. react-core's // useRenderToolCall reads copilotkit.renderToolCalls, so a renderer that // does not land there renders nowhere outside RN's own chat. const coreRef: { current: Core | null } = { current: null }; function Probe() { useRenderTool({ name: "showWeather", description: "Show weather", parameters: z.object({ city: z.string() }), render: () => null, }); return null; } render( , ); expect(coreRef.current!.renderToolCalls.map((r) => r.name)).toContain( "showWeather", ); }); }); // ─── Forwarded concern: handler ─────────────────────────────────────────────── describe("useRenderTool forwards handler", () => { it("runs the caller's handler when core executes the tool", async () => { // Observed through core's real execution path (`runTool` → the same // `executeToolHandler` an LLM-driven turn uses), not by inspecting the // registered object: a handler that is registered but never reached is // indistinguishable from a missing one to the app. const coreRef: { current: Core | null } = { current: null }; const handler = vi.fn( async (args: { city: string }) => `sunny in ${args.city}`, ); function Probe() { useRenderTool({ name: "getWeather", description: "Get weather", parameters: z.object({ city: z.string() }), render: () => null, handler, }); return null; } render( , ); let outcome: Awaited> | undefined; await act(async () => { outcome = await coreRef.current!.runTool({ name: "getWeather", parameters: { city: "Berlin" }, }); }); expect(handler).toHaveBeenCalledTimes(1); expect(handler.mock.calls[0]![0]).toEqual({ city: "Berlin" }); // Core writes the handler's return value into the tool result. Drop the // forwarding and this is the empty string a handler-less tool produces. expect(outcome!.result).toBe("sunny in Berlin"); }); }); // ─── Forwarded concern: agentId ─────────────────────────────────────────────── describe("useRenderTool forwards agentId", () => { it("scopes the tool and its renderer to that agent, not globally", () => { const coreRef: { current: Core | null } = { current: null }; function Probe() { useRenderTool({ name: "escalate", description: "Escalate to a human", parameters: z.object({ reason: z.string() }), render: () => null, agentId: "support", }); return null; } render( , ); const core = coreRef.current!; // Reachable for the agent it was scoped to … expect( core.getTool({ toolName: "escalate", agentId: "support" }), ).toBeDefined(); // … and NOT as a global tool. This is the assertion that bites: core's // `getTool` falls back to global tools only when `tool.agentId` is unset, // so a dropped agentId turns a scoped tool into one every agent can call. expect(core.getTool({ toolName: "escalate" })).toBeUndefined(); // The renderer half is keyed `${agentId}:${name}`, which is what lets a // scoped renderer coexist with a global one of the same name. const renderer = core.renderToolCalls.find((r) => r.name === "escalate"); expect(renderer).toBeDefined(); expect(renderer!.agentId).toBe("support"); }); }); // ─── Forwarded concern: deps ────────────────────────────────────────────────── /** * Paints whatever `label` the renderer captured at registration time, through * react-core's real `useRenderToolCall`. The render closure is deliberately * stale-able: `useFrontendTool` captures it in an effect, so the painted text * only changes when that effect re-runs. */ function LabelProbe({ label, deps, }: { label: string; deps: ReadonlyArray; }) { useRenderTool( { name: "showLabel", description: "Show a label", parameters: z.object({}), render: () => <>{label}, }, deps, ); const renderToolCall = useRenderToolCall(); return ( <> {renderToolCall({ toolCall: { id: "tc-label", type: "function", function: { name: "showLabel", arguments: "{}" }, }, })} ); } /** * A dep array holding a FRESH function identity on every call. `JSON.stringify` * flattens it to the constant `"[null]"`, so `useFrontendTool` can never see it * change however often the caller re-renders. */ const freshNonSerialisableDeps = (): ReadonlyArray => [ () => "callback", ]; describe("useRenderTool forwards deps", () => { it("re-registers the render closure when a dep changes", async () => { // The hook's own JSDoc promises exactly this: values a render closes over // must be passed in `deps` or the chat keeps painting the stale closure. // Without the forwarding, `useFrontendTool` sees an empty dep array, its // effect never re-runs, and "before" is painted forever. const { container, rerender } = render( , ); await waitFor(() => expect(container.textContent).toBe("before")); rerender( , ); await waitFor(() => expect(container.textContent).toBe("after")); }); it("PINS the limitation: a non-serialisable dep can never re-register", async () => { // `useFrontendTool` compares deps with `JSON.stringify(extraDeps)`, so a // function / Map / Set / private-field class instance collapses to a // CONSTANT and cannot trigger re-registration however often its identity // changes. This is a documented sharp edge, not a bug to fix here — it is // pinned so that changing the comparator (e.g. to reference equality) fails // loudly and forces the hook's JSDoc to be updated with it. const coreRef: { current: Core | null } = { current: null }; const { container, rerender } = render( , ); await waitFor(() => expect(container.textContent).toBe("before")); const registeredBefore = coreRef.current!.renderToolCalls.find( (r) => r.name === "showLabel", )!.render; rerender( , ); // Let any pending effect + subscriber notification settle, so this asserts // "nothing happened" rather than "nothing has happened yet". await act(async () => {}); expect( coreRef.current!.renderToolCalls.find((r) => r.name === "showLabel")! .render, ).toBe(registeredBefore); expect(container.textContent).toBe("before"); }); });