// @vitest-environment jsdom import type { ComponentProps, ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentActionButtons } from "./AgentActionButtons"; const mockNavigate = vi.hoisted(() => vi.fn()); const mockOpenNewIssue = vi.hoisted(() => vi.fn()); const mockPushToast = vi.hoisted(() => vi.fn()); const mockAgentsApi = vi.hoisted(() => ({ invoke: vi.fn(), pause: vi.fn(), resume: vi.fn(), clearError: vi.fn(), approve: vi.fn(), terminate: vi.fn(), resetSession: vi.fn(), instructionsBundle: vi.fn(), instructionsFile: vi.fn(), create: vi.fn(), hire: vi.fn(), })); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( {children} ), useNavigate: () => mockNavigate, })); vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: mockOpenNewIssue }), })); vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: mockPushToast }), })); vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { result = callback(); }); await result; } async function flushReact() { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } function makeAgent(overrides: Partial = {}): Agent { return { id: "agent-1", companyId: "company-1", name: "Alpha Agent", urlKey: "alpha", role: "engineer", title: null, icon: null, status: "active", reportsTo: null, capabilities: null, adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, pausedAt: null, permissions: { canCreateAgents: false }, lastHeartbeatAt: null, metadata: null, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), ...overrides, }; } describe("AgentActionButtons", () => { let container: HTMLDivElement; let root: ReturnType | null; let queryClient: QueryClient; let invalidateQueries: ReturnType; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") as unknown as ReturnType; mockAgentsApi.clearError.mockResolvedValue(makeAgent({ status: "idle" })); mockAgentsApi.pause.mockResolvedValue(makeAgent({ status: "paused" })); mockAgentsApi.resume.mockResolvedValue(makeAgent({ status: "idle" })); mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated" })); mockAgentsApi.invoke.mockResolvedValue({ id: "run-1" }); mockAgentsApi.resetSession.mockResolvedValue(undefined); }); afterEach(async () => { const currentRoot = root; if (currentRoot) { await act(async () => { currentRoot.unmount(); }); } queryClient.clear(); container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); }); function render(agent: Agent, props: Partial> = {}) { root ??= createRoot(container); root.render( , ); } it.each(["codex_local", "claude_local", "paperclip_runner"])("returns unfinished %s agents to their saved runner", async adapterType => { render(makeAgent({ adapterType, status: "paused", lifecycleState: "paused", pauseReason: "setup" })); await flushReact(); const finish = [...container.querySelectorAll("button")].find(button => button.textContent === "Finish setup"); expect(finish).toBeTruthy(); expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resume")).toBe(false); await act(() => finish!.click()); expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining("setupAgentId=agent-1")); expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining(`runner=${adapterType === "paperclip_runner" ? "paperclip" : "legacy"}`)); expect(mockAgentsApi.resume).not.toHaveBeenCalled(); }); it.each(["preparing", "verifying", "resuming"] as const)("offers pause rather than resume during %s", async lifecycleState => { render(makeAgent({ status: "paused", lifecycleState })); await flushReact(); const buttons = Array.from(container.querySelectorAll("button")); expect(buttons.some(button => button.textContent === "Resume")).toBe(false); const pause = buttons.find(button => button.textContent === "Pause")!; expect(pause.disabled).toBe(false); await act(async () => pause.click()); await flushReact(); expect(mockAgentsApi.pause).toHaveBeenCalledWith("agent-1", "company-1"); }); it.each(["pausing", "terminating", "cleaning_up", "terminated", "rejected"] as const)("disables pause and resume during %s", async lifecycleState => { render(makeAgent({ status: lifecycleState === "pausing" ? "paused" : "terminated", lifecycleState })); await flushReact(); const buttons = Array.from(container.querySelectorAll("button")); expect(buttons.find(button => button.textContent === "Pause")!.disabled).toBe(true); if (lifecycleState !== "pausing") { expect(buttons.find(button => button.textContent === "Run Heartbeat")!.disabled).toBe(true); expect(buttons.find(button => button.textContent === "Assign Task")!.disabled).toBe(true); } }); it("replaces the pause slot with Clear error for error agents", async () => { render(makeAgent({ status: "error" })); await flushReact(); expect(container.textContent).toContain("Clear error"); expect(container.textContent).not.toContain("Pause"); expect(container.textContent).toContain("Run Heartbeat"); await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')?.click(); }); await flushReact(); expect(document.body.textContent).toContain("Reset Sessions"); }); it("calls clearError and refreshes agent-related queries", async () => { render(makeAgent({ status: "error" })); await flushReact(); await act(async () => { container.querySelector('[aria-label="Clear error and return agent to idle"]')?.click(); }); await flushReact(); expect(mockAgentsApi.clearError).toHaveBeenCalledWith("agent-1", "company-1"); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "runtime-state", "agent-1"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "task-sessions", "agent-1"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["live-runs", "company-1"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["heartbeats", "company-1", "agent-1"] }); }); it("keeps the normal pause action for non-error agents", async () => { render(makeAgent({ status: "active" })); await flushReact(); expect(container.textContent).toContain("Pause"); expect(container.textContent).not.toContain("Clear error"); }); it.each([false, true])("keeps the normal run action when persistent tracing is %s", async tracingEnabled => { render(makeAgent({ runtimeConfig: tracingEnabled ? { debug: { providerTrace: "raw" } } : {} })); await flushReact(); const buttons = Array.from(container.querySelectorAll("button")); expect(buttons.some(button => button.textContent?.includes("Run with provider trace"))).toBe(false); expect(container.textContent?.includes("Raw tracing on")).toBe(tracingEnabled); await act(async () => { buttons.find(button => button.textContent === "Run Heartbeat")!.click(); }); await flushReact(); expect(mockAgentsApi.invoke).toHaveBeenCalledExactlyOnceWith("agent-1", "company-1"); expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1"); }); it.each(["terminating", "cleaning_up"] as const)("keeps the detail page open during %s", async lifecycleState => { mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated", lifecycleState })); const onTerminateSuccess = vi.fn(); render(makeAgent(), { onTerminateSuccess }); await flushReact(); await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')!.click(); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll("button")).find(button => button.textContent?.includes("Terminate"))!.click(); }); await flushReact(); expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1"); expect(onTerminateSuccess).not.toHaveBeenCalled(); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); }); it("calls the terminate success handler after terminating an agent", async () => { const onTerminateSuccess = vi.fn(); render(makeAgent(), { onTerminateSuccess }); await flushReact(); await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')?.click(); }); await flushReact(); const terminateButton = Array.from(document.body.querySelectorAll("button")) .find((button) => button.textContent?.includes("Terminate")); expect(terminateButton).toBeTruthy(); await act(async () => { terminateButton?.click(); }); await flushReact(); expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1"); expect(onTerminateSuccess).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-1", status: "terminated", })); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] }); }); it("does not terminate when navigation away from a dirty detail page is rejected", async () => { const onBeforeNavigate = vi.fn().mockReturnValue(false); render(makeAgent(), { onBeforeNavigate, onTerminateSuccess: vi.fn() }); await flushReact(); await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')?.click(); }); await flushReact(); const terminateButton = Array.from(document.body.querySelectorAll("button")) .find((button) => button.textContent?.includes("Terminate")); await act(async () => { terminateButton?.click(); }); await flushReact(); expect(onBeforeNavigate).toHaveBeenCalledOnce(); expect(mockAgentsApi.terminate).not.toHaveBeenCalled(); }); it("rechecks navigation when the form becomes dirty while termination is pending", async () => { let resolveTermination!: (agent: Agent) => void; mockAgentsApi.terminate.mockReturnValue(new Promise((resolve) => { resolveTermination = resolve; })); const onBeforeNavigate = vi.fn().mockReturnValueOnce(true).mockReturnValue(false); const onTerminateSuccess = vi.fn(); const agent = makeAgent(); render(agent, { hasPendingNavigationChanges: false, onBeforeNavigate, onTerminateSuccess, }); await flushReact(); await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')?.click(); }); await flushReact(); const terminateButton = Array.from(document.body.querySelectorAll("button")) .find((button) => button.textContent?.includes("Terminate")); await act(async () => { terminateButton?.click(); }); await flushReact(); render(agent, { hasPendingNavigationChanges: true, onBeforeNavigate, onTerminateSuccess, }); await flushReact(); resolveTermination(makeAgent({ status: "terminated" })); await flushReact(); expect(onBeforeNavigate).toHaveBeenCalledTimes(2); expect(onTerminateSuccess).not.toHaveBeenCalled(); }); });