import { render } from "@testing-library/react"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { ReactFrontendTool } from "../../types/frontend-tool"; import type { ReactToolCallRenderer } from "../../types"; import { CopilotKitProvider, useCopilotKit, type CopilotKitContextValue, } from "../CopilotKitProvider"; import { CopilotKitCoreReact } from "../../lib/react-core"; import { useFrontendTool } from "../../hooks/use-frontend-tool"; // Mock console methods to suppress expected warnings let consoleErrorSpy: ReturnType; let consoleWarnSpy: ReturnType; beforeEach(() => { consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { consoleErrorSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); describe("CopilotKitProvider stability", () => { describe("instance stability", () => { it("returns the same copilotkit instance after re-render with new renderToolCalls array", () => { const instances: CopilotKitCoreReact[] = []; function Collector({ children }: { children?: React.ReactNode }) { const { copilotkit } = useCopilotKit(); instances.push(copilotkit); return <>{children}; } const renderToolCalls1: ReactToolCallRenderer[] = [ { name: "tool1", args: z.object({ a: z.string() }), render: () =>
Tool 1
, }, ]; const renderToolCalls2: ReactToolCallRenderer[] = [ { name: "tool1", args: z.object({ a: z.string() }), render: () =>
Tool 1 updated
, }, ]; const { rerender } = render( , ); rerender( , ); expect(instances.length).toBeGreaterThanOrEqual(2); const first = instances[0]; for (const instance of instances) { expect(instance).toBe(first); } }); it("returns the same copilotkit instance after re-render with new frontendTools array", () => { const instances: CopilotKitCoreReact[] = []; function Collector() { const { copilotkit } = useCopilotKit(); instances.push(copilotkit); return null; } const tools1: ReactFrontendTool[] = [ { name: "toolA", description: "Tool A", handler: vi.fn() }, ]; const tools2: ReactFrontendTool[] = [ { name: "toolB", description: "Tool B", handler: vi.fn() }, ]; const { rerender } = render( , ); rerender( , ); expect(instances.length).toBeGreaterThanOrEqual(2); const first = instances[0]; for (const instance of instances) { expect(instance).toBe(first); } }); }); describe("context value stability", () => { it("does not change context value reference when only tools change", () => { const contextValues: CopilotKitContextValue[] = []; function Collector() { const context = useCopilotKit(); contextValues.push(context); return null; } const tools1: ReactFrontendTool[] = [ { name: "toolA", description: "Tool A" }, ]; const tools2: ReactFrontendTool[] = [ { name: "toolB", description: "Tool B" }, ]; const { rerender } = render( , ); const initialContext = contextValues[contextValues.length - 1]; rerender( , ); const afterRerender = contextValues[contextValues.length - 1]; expect(afterRerender?.copilotkit).toBe(initialContext?.copilotkit); expect(afterRerender?.executingToolCallIds).toBe( initialContext?.executingToolCallIds, ); }); }); describe("setter calls on prop changes", () => { it("calls setTools when frontendTools change instead of recreating instance", () => { const setToolsSpy = vi.fn(); let spyAttached = false; function SpyAttacher() { const { copilotkit } = useCopilotKit(); if (!spyAttached) { const originalSetTools = copilotkit.setTools.bind(copilotkit); copilotkit.setTools = (tools) => { setToolsSpy(tools); return originalSetTools(tools); }; spyAttached = true; } return null; } const tools1: ReactFrontendTool[] = [ { name: "toolA", description: "Tool A", handler: vi.fn() }, ]; const tools2: ReactFrontendTool[] = [ { name: "toolB", description: "Tool B", handler: vi.fn() }, ]; const { rerender } = render( , ); setToolsSpy.mockClear(); rerender( , ); expect(setToolsSpy).toHaveBeenCalled(); }); it("calls setRenderToolCalls when renderToolCalls change", () => { const setRenderToolCallsSpy = vi.fn(); let spyAttached = false; function SpyAttacher() { const { copilotkit } = useCopilotKit(); if (!spyAttached) { const original = copilotkit.setRenderToolCalls.bind(copilotkit); copilotkit.setRenderToolCalls = (renderToolCalls) => { setRenderToolCallsSpy(renderToolCalls); return original(renderToolCalls); }; spyAttached = true; } return null; } const rtc1: ReactToolCallRenderer[] = [ { name: "render1", args: z.object({ x: z.string() }), render: () =>
R1
, }, ]; const rtc2: ReactToolCallRenderer[] = [ { name: "render2", args: z.object({ y: z.string() }), render: () =>
R2
, }, ]; const { rerender } = render( , ); setRenderToolCallsSpy.mockClear(); rerender( , ); expect(setRenderToolCallsSpy).toHaveBeenCalled(); }); }); describe("no unnecessary re-renders from stable props", () => { it("does not re-render children when provider re-renders with same stable props", () => { let childRenderCount = 0; function Child() { childRenderCount++; useCopilotKit(); return
child
; } const stableTools: ReactFrontendTool[] = [ { name: "tool1", description: "Tool 1" }, ]; const { rerender } = render( , ); const initialCount = childRenderCount; rerender( , ); expect(childRenderCount - initialCount).toBeLessThanOrEqual(1); }); }); describe("setter effects skip initial mount (didMountRef guard)", () => { it("does not call setTools on initial mount (constructor handles it)", () => { const setToolsCalls: unknown[][] = []; let spyAttached = false; function SpyAttacher() { const { copilotkit } = useCopilotKit(); if (!spyAttached) { const originalSetTools = copilotkit.setTools.bind(copilotkit); copilotkit.setTools = (tools) => { setToolsCalls.push([tools]); return originalSetTools(tools); }; spyAttached = true; } return null; } const tools: ReactFrontendTool[] = [ { name: "toolA", description: "Tool A", handler: vi.fn() }, ]; render( , ); // setTools should NOT have been called on initial mount // because the constructor already sets the initial tools // and the didMountRef guard skips the first effect invocation. expect(setToolsCalls).toHaveLength(0); }); it("does not call setRenderToolCalls on initial mount", () => { const calls: unknown[][] = []; let spyAttached = false; function SpyAttacher() { const { copilotkit } = useCopilotKit(); if (!spyAttached) { const original = copilotkit.setRenderToolCalls.bind(copilotkit); copilotkit.setRenderToolCalls = (renderToolCalls) => { calls.push([renderToolCalls]); return original(renderToolCalls); }; spyAttached = true; } return null; } const rtc: ReactToolCallRenderer[] = [ { name: "render1", args: z.object({ x: z.string() }), render: () =>
R1
, }, ]; render( , ); // Provider setter effects are skipped on mount; // only the constructor sets the initial render tool calls. expect(calls).toHaveLength(0); }); }); describe("dynamic tool preservation on mount", () => { it("preserves dynamically registered tools from child hooks after provider mounts", () => { let capturedInstance: CopilotKitCoreReact | null = null; function DynamicToolChild() { const { copilotkit } = useCopilotKit(); capturedInstance = copilotkit; // Register a tool dynamically via the hook useFrontendTool({ name: "dynamicTool", description: "A dynamically registered tool", handler: async () => "result", }); return null; } // Provider has its own tool via props const providerTools: ReactFrontendTool[] = [ { name: "providerTool", description: "From provider props", handler: vi.fn(), }, ]; render( , ); // Both the provider tool (from constructor) and the dynamic tool // (from useFrontendTool hook) should exist on the instance. // If the provider's setter effects ran on mount and called setTools(), // the dynamic tool would be wiped out. expect(capturedInstance).not.toBeNull(); const dynamicTool = capturedInstance!.getTool({ toolName: "dynamicTool", }); const providerTool = capturedInstance!.getTool({ toolName: "providerTool", }); expect(dynamicTool).toBeDefined(); expect(providerTool).toBeDefined(); }); it("preserves dynamically registered render tool calls from child hooks after provider mounts", () => { let capturedInstance: CopilotKitCoreReact | null = null; function DynamicRenderChild() { const { copilotkit } = useCopilotKit(); capturedInstance = copilotkit; useFrontendTool({ name: "renderableTool", description: "Has a render function", parameters: z.object({ msg: z.string() }), handler: async () => "ok", render: () =>
Rendered!
, }); return null; } const providerRtc: ReactToolCallRenderer[] = [ { name: "providerRenderer", args: z.object({ x: z.string() }), render: () =>
Provider Render
, }, ]; render( , ); expect(capturedInstance).not.toBeNull(); const renderToolCalls = capturedInstance!.renderToolCalls; // Both the provider-level renderer and the hook-registered renderer // should exist. If setter effects ran on mount, only the provider // renderer would remain. const providerRenderer = renderToolCalls.find( (r) => r.name === "providerRenderer", ); const hookRenderer = renderToolCalls.find( (r) => r.name === "renderableTool", ); expect(providerRenderer).toBeDefined(); expect(hookRenderer).toBeDefined(); }); }); describe("React.StrictMode", () => { it("returns the same copilotkit instance in StrictMode", () => { const instances: CopilotKitCoreReact[] = []; function Collector() { const { copilotkit } = useCopilotKit(); instances.push(copilotkit); return null; } render( , ); // StrictMode double-renders in dev, so we expect multiple captures expect(instances.length).toBeGreaterThanOrEqual(2); const first = instances[0]; for (const instance of instances) { expect(instance).toBe(first); } }); it("calls setTools at most once during StrictMode mount cycle", () => { const setToolsCalls: unknown[][] = []; let spyAttached = false; function SpyAttacher() { const { copilotkit } = useCopilotKit(); if (!spyAttached) { const originalSetTools = copilotkit.setTools.bind(copilotkit); copilotkit.setTools = (tools) => { setToolsCalls.push([tools]); return originalSetTools(tools); }; spyAttached = true; } return null; } const tools: ReactFrontendTool[] = [ { name: "toolA", description: "Tool A", handler: vi.fn() }, ]; render( , ); // StrictMode fires effects twice (mount → cleanup → remount). // The didMountRef guard skips the initial mount. After cleanup, // didMountRef.current stays true (refs persist), so the remount // call fires setTools once. This is expected and harmless — it // sets the same tools the constructor already established. // The critical invariant (dynamic tools not overwritten) is // verified by the separate "preserves dynamically registered // tools through StrictMode remount cycle" test. expect(setToolsCalls.length).toBeLessThanOrEqual(1); }); it("preserves dynamically registered tools through StrictMode remount cycle", () => { let capturedInstance: CopilotKitCoreReact | null = null; function DynamicToolChild() { const { copilotkit } = useCopilotKit(); capturedInstance = copilotkit; useFrontendTool({ name: "strictModeTool", description: "Survives StrictMode remount", handler: async () => "ok", }); return null; } render( , ); expect(capturedInstance).not.toBeNull(); const tool = capturedInstance!.getTool({ toolName: "strictModeTool" }); expect(tool).toBeDefined(); }); it("preserves dynamically registered render tool calls through StrictMode remount cycle", () => { let capturedInstance: CopilotKitCoreReact | null = null; function DynamicRenderChild() { const { copilotkit } = useCopilotKit(); capturedInstance = copilotkit; useFrontendTool({ name: "strictModeRenderTool", description: "Has render, survives StrictMode remount", parameters: z.object({ topic: z.string() }), handler: async () => "ok", render: () =>
Rendered!
, }); return null; } render( , ); expect(capturedInstance).not.toBeNull(); const renderToolCalls = capturedInstance!.renderToolCalls; const hookRenderer = renderToolCalls.find( (r) => r.name === "strictModeRenderTool", ); expect(hookRenderer).toBeDefined(); }); it("hook render entries coexist with prop render entries through StrictMode remount", () => { let capturedInstance: CopilotKitCoreReact | null = null; function DynamicRenderChild() { const { copilotkit } = useCopilotKit(); capturedInstance = copilotkit; useFrontendTool({ name: "hookTool", description: "Registered via hook", parameters: z.object({ x: z.string() }), handler: async () => "ok", render: () =>
Hook render
, }); return null; } const propRtc: ReactToolCallRenderer[] = [ { name: "propTool", args: z.object({ y: z.string() }), render: () =>
Prop render
, }, ]; render( , ); expect(capturedInstance).not.toBeNull(); const renderToolCalls = capturedInstance!.renderToolCalls; expect(renderToolCalls.find((r) => r.name === "propTool")).toBeDefined(); expect(renderToolCalls.find((r) => r.name === "hookTool")).toBeDefined(); }); it("context value is stable through StrictMode remount", () => { const contextValues: CopilotKitContextValue[] = []; function Collector() { const context = useCopilotKit(); contextValues.push(context); return null; } render( , ); expect(contextValues.length).toBeGreaterThanOrEqual(2); const first = contextValues[0]!; for (const ctx of contextValues) { expect(ctx.copilotkit).toBe(first.copilotkit); expect(ctx.executingToolCallIds).toBe(first.executingToolCallIds); } }); }); describe("hook render entries survive prop changes", () => { it("preserves hook-registered render entries when provider renderToolCalls prop changes", () => { let capturedInstance: CopilotKitCoreReact | null = null; function DynamicRenderChild() { const { copilotkit } = useCopilotKit(); capturedInstance = copilotkit; useFrontendTool({ name: "hookTool", description: "Registered via hook", parameters: z.object({ x: z.string() }), handler: async () => "ok", render: () =>
Hook render
, }); return null; } const rtc1: ReactToolCallRenderer[] = [ { name: "propToolA", args: z.object({ a: z.string() }), render: () =>
A
, }, ]; const rtc2: ReactToolCallRenderer[] = [ { name: "propToolB", args: z.object({ b: z.string() }), render: () =>
B
, }, ]; const { rerender } = render( , ); // Rerender with new prop render tool calls rerender( , ); expect(capturedInstance).not.toBeNull(); const renderToolCalls = capturedInstance!.renderToolCalls; // propToolA should be gone (replaced by propToolB) expect( renderToolCalls.find((r) => r.name === "propToolA"), ).toBeUndefined(); // propToolB should exist (from new props) expect(renderToolCalls.find((r) => r.name === "propToolB")).toBeDefined(); // hookTool should survive the prop change expect(renderToolCalls.find((r) => r.name === "hookTool")).toBeDefined(); }); }); describe("runtimeUrl deduplication", () => { it("always calls setRuntimeUrl with the same URL on re-render (AgentRegistry deduplicates)", () => { const setRuntimeUrlCalls: unknown[] = []; let spyAttached = false; function SpyAttacher() { const { copilotkit } = useCopilotKit(); if (!spyAttached) { const original = copilotkit.setRuntimeUrl.bind(copilotkit); copilotkit.setRuntimeUrl = (...args: [string | undefined]) => { setRuntimeUrlCalls.push(args[0]); return original(...args); }; spyAttached = true; } return null; } const { rerender } = render( , ); // Re-render with the SAME runtimeUrl rerender( , ); // The config effect may re-fire if other deps (mergedHeaders, etc.) // change reference on rerender. The actual deduplication of /info // fetches happens inside AgentRegistry.setRuntimeUrl(), which has // a guard: `if (this._runtimeUrl === normalizedRuntimeUrl) return`. // Here we verify every call receives the same URL. expect(setRuntimeUrlCalls.length).toBeGreaterThanOrEqual(1); for (const url of setRuntimeUrlCalls) { expect(url).toBe("http://localhost:3000/api"); } }); }); });