import { vi } from "vitest"; import React from "react"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import { useCoAgentStateRender } from "../use-coagent-state-render"; import { CoAgentStateRenderBridge } from "../use-coagent-state-render-bridge"; import { useCopilotChatInternal } from "../use-copilot-chat_internal"; import { CoAgentStateRendersProvider, CopilotContext, useCoAgentStateRenders, } from "../../context"; import type { Claim } from "../use-coagent-state-render-bridge.helpers"; import { createTestCopilotContext } from "../../test-helpers/copilot-context"; import { useRenderCustomMessages } from "../../v2"; type TestMessage = { id: string; role: "user" | "assistant" | "system" | string; content?: string; }; type TestAgentSubscriber = { onStateChanged?: (args?: { state?: Record }) => void; onStepStartedEvent?: (args: { event: { stepName: string } }) => void; onStepFinishedEvent?: (args: { event: { stepName: string } }) => void; }; const mockAgent = { messages: [] as TestMessage[], state: {}, isRunning: true, subscribe: vi.fn(), setMessages: vi.fn(), setState: vi.fn(), addMessage: vi.fn(), abortRun: vi.fn(), runAgent: vi.fn(), detachActiveRun: vi.fn().mockResolvedValue(undefined), }; let lastSubscriber: TestAgentSubscriber | null = null; vi.mock("../../v2", () => ({ useAgent: vi.fn(() => ({ agent: mockAgent })), useCopilotKit: vi.fn(() => ({ copilotkit: { connectAgent: vi.fn(), getRunIdForMessage: vi.fn(), runAgent: vi.fn(), clearSuggestions: vi.fn(), addSuggestionsConfig: vi.fn(), reloadSuggestions: vi.fn(), interruptElement: null, subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })), }, })), useCopilotChatConfiguration: vi.fn(() => ({ agentId: "test-agent" })), useRenderCustomMessages: vi.fn(() => undefined), useSuggestions: vi.fn(() => ({ suggestions: [], isLoading: false })), })); vi.mock("../../components/toast/toast-provider", () => ({ useToast: () => ({ setBannerError: vi.fn(), addToast: vi.fn(), }), })); vi.mock("../../components/error-boundary/error-utils", () => ({ useAsyncCallback: unknown>(fn: T) => fn, })); vi.mock("../use-lazy-tool-renderer", () => ({ useLazyToolRenderer: vi.fn(() => () => null), })); function TestHarness({ snapshot }: { snapshot: string }) { useCoAgentStateRender<{ current_step?: string }>({ name: "test-agent", render: ({ state }) => (
{state.current_step ?? "none"}
), }); return ( ); } function SnapshotHarness({ snapshot, message, }: { snapshot: string; message: TestMessage; }) { useCoAgentStateRender<{ current_step?: string }>({ name: "test-agent", render: ({ state }) => (
{state.current_step ?? "none"}
), }); return ( ); } function LiveStateHarness({ message, }: { message: Pick; }) { useCoAgentStateRender<{ current_step?: string }>({ name: "test-agent", render: ({ state }) => (
{state.current_step ?? "none"}
), }); return ( ); } function NonFirstMessageHarness({ snapshot }: { snapshot: string }) { useCoAgentStateRender<{ current_step?: string }>({ name: "test-agent", render: ({ state }) => (
{state.current_step ?? "none"}
), }); return ( ); } function MultiRunHarness({ snapshot, runId, messageId, messageIndex, }: { snapshot: string; runId: string; messageId: string; messageIndex: number; }) { useCoAgentStateRender<{ current_step?: string }>({ name: "test-agent", render: ({ state }) => (
{state.current_step ?? "none"}
), }); return ( ); } function ClaimsObserver({ onChange, }: { onChange: (claims: Record) => void; }) { const { claimsRef } = useCoAgentStateRenders(); React.useEffect(() => { onChange(claimsRef.current as Record); }); return null; } function ChatHarness({ tick }: { tick: number }) { useCoAgentStateRender<{ current_step?: string }>({ name: "test-agent", render: ({ state }) => (
{state.current_step ?? "none"}
), }); const { messages } = useCopilotChatInternal(); return ( <> {messages.map((message) => message.generativeUI ? (
{message.generativeUI()}
) : null, )}
{tick}
); } describe("useCoAgentStateRender", () => { beforeEach(() => { lastSubscriber = null; mockAgent.state = {}; mockAgent.messages = []; mockAgent.subscribe.mockImplementation( (subscriber: TestAgentSubscriber) => { lastSubscriber = subscriber; return { unsubscribe: vi.fn() }; }, ); }); it("re-renders when state snapshots change", async () => { const copilotContextValue = createTestCopilotContext(); const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); rerender( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Thinking..."); }); }); it("renders snapshots that arrive before assistant text", async () => { const copilotContextValue = createTestCopilotContext(); const baseMessage = { id: "msg-early", role: "assistant", content: "" }; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); rerender( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Thinking..."); }); rerender( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Finalizing..."); }); }); it("re-renders from live agent state updates", async () => { const copilotContextValue = createTestCopilotContext(); render( , ); mockAgent.state = { current_step: "Processing..." }; act(() => { lastSubscriber?.onStateChanged?.({ state: mockAgent.state }); }); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); mockAgent.state = { current_step: "Thinking..." }; act(() => { lastSubscriber?.onStateChanged?.({ state: mockAgent.state }); }); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Thinking..."); }); }); it("renders state snapshots before any assistant message exists", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [{ id: "msg-user-1", role: "user", content: "Hi" }]; mockAgent.isRunning = false; mockAgent.state = {}; const { rerender } = render( , ); const placeholderTestId = "message-coagent-state-render-test-agent-pending:msg-user-1"; expect(screen.queryByTestId(placeholderTestId)).toBeNull(); mockAgent.isRunning = true; mockAgent.state = { current_step: "Processing..." }; rerender( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); expect(screen.getByTestId(placeholderTestId)).toBeTruthy(); const placeholderStep = screen.getByTestId("state").textContent; mockAgent.isRunning = false; mockAgent.state = {}; mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; rerender( , ); await waitFor(() => { expect(screen.queryByTestId(placeholderTestId)).toBeNull(); expect(screen.getByTestId("message-msg-assistant-1")).toBeTruthy(); expect(screen.getByTestId("state").textContent).toBe("none"); }); }); it("does not render placeholder until the agent is running or has state", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [{ id: "msg-user-1", role: "user", content: "Hi" }]; mockAgent.isRunning = false; mockAgent.state = {}; render( , ); await waitFor(() => { expect( screen.queryByTestId( "message-coagent-state-render-test-agent-pending:msg-user-1", ), ).toBeNull(); }); }); it("renders for non-first messages in a run", async () => { const copilotContextValue = createTestCopilotContext(); render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); }); it("falls back to legacy renderer when renderCustomMessages throws", async () => { vi.mocked(useRenderCustomMessages).mockImplementationOnce(() => () => { throw new Error("boom"); }); const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "Processing..." }; render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); }); it("prefers legacy renderer over renderCustomMessages when both exist", async () => { const renderCustomSpy = vi.fn(() => null); vi.mocked(useRenderCustomMessages).mockImplementationOnce( () => renderCustomSpy, ); const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "Processing..." }; render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); expect(renderCustomSpy).not.toHaveBeenCalled(); }); it("renders empty state when agent state clears after completion", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "Processing..." }; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); mockAgent.isRunning = false; mockAgent.state = {}; rerender( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("none"); }); }); it("allows independent renders across different runs with the same snapshot", async () => { const copilotContextValue = createTestCopilotContext(); const snapshot = JSON.stringify({ current_step: "Processing..." }); render( , ); await waitFor(() => { const nodes = screen.getAllByTestId("state"); expect(nodes).toHaveLength(2); expect(nodes[0].textContent).toBe("Processing..."); expect(nodes[1].textContent).toBe("Processing..."); }); }); it("lets newer assistant messages claim even if snapshot matches", async () => { const copilotContextValue = createTestCopilotContext(); const snapshot = JSON.stringify({ current_step: "Processing..." }); render( , ); await waitFor(() => { const nodes = screen.getAllByTestId("state"); expect(nodes).toHaveLength(2); expect(nodes[0].textContent).toBe("Processing..."); expect(nodes[1].textContent).toBe("Processing..."); }); }); it("locks older claims when a newer message claims", async () => { const copilotContextValue = createTestCopilotContext(); const snapshot = JSON.stringify({ current_step: "Processing..." }); let latestClaims: Record = {}; render( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-1"]).toBeTruthy(); expect(latestClaims["msg-2"]).toBeTruthy(); expect(latestClaims["msg-1"].locked).toBe(true); }); }); it("keeps older message snapshots stable when a new run starts", async () => { const copilotContextValue = createTestCopilotContext(); const snapshot = JSON.stringify({ current_step: "Processing..." }); let latestClaims: Record = {}; const { rerender } = render( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-1"]?.stateSnapshot?.current_step).toBe( "Processing...", ); }); rerender( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-1"]?.stateSnapshot?.current_step).toBe( "Processing...", ); expect(latestClaims["msg-2"]?.stateSnapshot?.current_step).toBe( "Processing...", ); }); }); it("does not overwrite a previous run snapshot when a new run has different state", async () => { const copilotContextValue = createTestCopilotContext(); const snapshotRun1 = JSON.stringify({ current_step: "Processing..." }); const snapshotRun2 = JSON.stringify({ current_step: "Finalizing..." }); let latestClaims: Record = {}; const { rerender } = render( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-1"]?.stateSnapshot?.current_step).toBe( "Processing...", ); }); rerender( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-1"]?.stateSnapshot?.current_step).toBe( "Processing...", ); expect(latestClaims["msg-2"]?.stateSnapshot?.current_step).toBe( "Finalizing...", ); }); }); it("locks a claim when newer agent messages exist", async () => { const copilotContextValue = createTestCopilotContext(); const snapshot = JSON.stringify({ current_step: "Processing..." }); let latestClaims: Record = {}; mockAgent.messages = [ { id: "msg-1", role: "assistant", content: "" }, { id: "msg-2", role: "assistant", content: "" }, ]; render( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-1"]).toBeTruthy(); expect(latestClaims["msg-1"].locked).toBe(true); }); }); it("does not update a locked claim from new agent state", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); let latestClaims: Record = {}; mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "First" }; const { rerender } = render( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-assistant-1"]?.stateSnapshot?.current_step).toBe( "First", ); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, { id: "msg-user-2", role: "user", content: "Next" }, ]; mockAgent.state = { current_step: "Second" }; rerender( (latestClaims = claims)} /> , ); await waitFor(() => { expect(latestClaims["msg-assistant-1"]?.stateSnapshot?.current_step).toBe( "First", ); }); }); it("renders new run snapshots instead of reusing the previous run state", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "First run" }; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("First run"); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, { id: "msg-user-2", role: "user", content: "Next" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "Second run" }; rerender( , ); await waitFor(() => { const stateNodes = screen.getAllByTestId("state"); const values = stateNodes.map((node) => node.textContent); expect(values).toContain("Second run"); }); }); it("does not reuse latest cached snapshot for a new run placeholder before snapshots arrive", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "First run" }; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("First run"); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, { id: "msg-user-2", role: "user", content: "Next" }, ]; mockAgent.isRunning = true; mockAgent.state = {}; rerender( , ); await waitFor(() => { const placeholder = screen.getByTestId( "message-coagent-state-render-test-agent-pending:msg-user-2", ); expect(placeholder.textContent).toContain("none"); }); }); it("does not show the previous snapshot for a new assistant message before its snapshot arrives", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "First run" }; const { rerender } = render( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-1"); expect(within(message).getByTestId("state").textContent).toBe( "First run", ); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, { id: "msg-user-2", role: "user", content: "Next" }, { id: "msg-assistant-2", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = {}; rerender( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-2"); expect(within(message).getByTestId("state").textContent).toBe("none"); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, { id: "msg-user-2", role: "user", content: "Next" }, { id: "msg-assistant-2", role: "assistant", content: "", state: '{"current_step":"Second run"}', }, ]; rerender( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-2"); expect(within(message).getByTestId("state").textContent).toBe( "Second run", ); }); }); it("does not show previous live state for a new run before the run updates state", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "First run" }; const { rerender } = render( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-1"); expect(within(message).getByTestId("state").textContent).toBe( "First run", ); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi" }, { id: "msg-assistant-1", role: "assistant", content: "" }, { id: "msg-user-2", role: "user", content: "Next" }, { id: "msg-assistant-2", role: "assistant", content: "" }, ]; mockAgent.isRunning = true; // live state still shows the previous run mockAgent.state = { current_step: "First run" }; rerender( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-2"); expect(within(message).getByTestId("state").textContent).toBe("none"); }); mockAgent.state = { current_step: "Second run" }; act(() => { lastSubscriber?.onStateChanged?.({ state: mockAgent.state }); }); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-2"); expect(within(message).getByTestId("state").textContent).toBe( "Second run", ); }); }); it("renders an explicit empty state snapshot", async () => { const copilotContextValue = createTestCopilotContext({ threadId: "thread-1", agentSession: null, }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi", }, { id: "msg-assistant-1", role: "assistant", content: "", state: '{"current_step":"Processing..."}', }, ]; mockAgent.isRunning = true; mockAgent.state = { current_step: "Processing..." }; const { rerender } = render( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-1"); expect(within(message).getByTestId("state").textContent).toBe( "Processing...", ); }); mockAgent.messages = [ { id: "msg-user-1", role: "user", content: "Hi", }, { id: "msg-assistant-1", role: "assistant", content: "", state: "{}", }, ]; rerender( , ); await waitFor(() => { const message = screen.getByTestId("message-msg-assistant-1"); expect(within(message).getByTestId("state").textContent).toBe("none"); }); }); it("renders an empty live state update", async () => { const copilotContextValue = createTestCopilotContext(); render( , ); mockAgent.state = { current_step: "Processing..." }; act(() => { lastSubscriber?.onStateChanged?.({ state: mockAgent.state }); }); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("Processing..."); }); mockAgent.state = {}; act(() => { lastSubscriber?.onStateChanged?.({ state: mockAgent.state }); }); await waitFor(() => { expect(screen.getByTestId("state").textContent).toBe("none"); }); }); });