diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 8b511c038fe..b745f8de2dd 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -34,8 +34,11 @@ Use New agent from the roster or `/agents/new`. Submitting the name creates an inactive record before adapter selection or account setup. A name-only draft returns to adapter selection when reopened. Save & exit keeps its configuration; Finish setup on the agent returns to the -same record. Final submission requests approval when required, then follows the -existing preparation and verification lifecycle. +same record. Open an unfinished agent from the roster to reach its header. +The header shows Finish setup instead of Run or Resume, including during computer +preparation and verification. Terminating agents cannot reopen setup. Final +submission requests approval when required, then follows the existing preparation +and verification lifecycle. Agent-owned plugin environments prepare the saved draft in the background when their driver declares `requiresAgent`. The draft waits in `verifying` with its diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 4f8299ade88..bf0f86d916b 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -136,6 +136,12 @@ test.describe("Onboarding wizard", () => { await expect(page.getByRole("heading", { name: "Choose an adapter", exact: true })).toBeVisible(); await page.keyboard.press("Escape"); await page.getByRole("main").getByRole("link", { name: /^Atlas / }).click(); + await expect(page.getByRole("button", { name: "Run now", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Resume", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Finish setup", exact: true })).toHaveCount(1); + await page.reload(); + await expect(page.getByRole("button", { name: "Finish setup", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Run now", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "Finish setup", exact: true }).click(); await expect(page.getByRole("heading", { name: "Choose an adapter", exact: true })).toBeVisible(); await page.locator("label").filter({ has: page.locator('input[name="new-agent-adapter"][value="codex_local"]') }).click(); diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index 1177ccb02d1..442f75d6ee7 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -7,11 +7,14 @@ 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"; +import { useAgentLifecycleStatus } from "./AgentLifecycleStatus"; +import { queryKeys } from "../lib/queryKeys"; const mockNavigate = vi.hoisted(() => vi.fn()); const mockOpenNewIssue = vi.hoisted(() => vi.fn()); const mockPushToast = vi.hoisted(() => vi.fn()); const mockAgentsApi = vi.hoisted(() => ({ + get: vi.fn(), invoke: vi.fn(), pause: vi.fn(), resume: vi.fn(), @@ -134,7 +137,7 @@ describe("AgentActionButtons", () => { ); } - it.each(["codex_local", "claude_local", "paperclip_runner"])("returns unfinished %s agents to their saved runner", async adapterType => { + it.each(["codex_local", "claude_local", "opencode_local", "pi_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"); @@ -146,6 +149,84 @@ describe("AgentActionButtons", () => { expect(mockAgentsApi.resume).not.toHaveBeenCalled(); }); + it.each(["paused", "preparing", "verifying", "pausing", undefined] as const)("replaces run controls with one setup action when lifecycle is %s", async lifecycleState => { + render(makeAgent({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" }, status: "paused", lifecycleState, pauseReason: "setup" })); + await flushReact(); + const buttons = [...container.querySelectorAll("button")]; + const finish = buttons.filter(button => button.textContent === "Finish setup"); + expect(finish).toHaveLength(1); + expect(buttons.some(button => /Run Heartbeat|^Resume$|^Pause$/.test(button.textContent ?? ""))).toBe(false); + await act(() => finish[0].click()); + const target = new URL(mockNavigate.mock.calls[0][0], "https://example.test"); + expect(target.pathname).toBe("/agents/new"); + expect(Object.fromEntries(target.searchParams)).toMatchObject({ setupAgentId: "agent-1", adapterType: "paperclip_runner", runnerProvider: "claude", runner: "paperclip" }); + expect(mockAgentsApi.invoke).not.toHaveBeenCalled(); + expect(mockAgentsApi.resume).not.toHaveBeenCalled(); + }); + + it.each(["terminating", "cleaning_up", "terminated", "rejected"] as const)("does not reopen setup during %s", async lifecycleState => { + render(makeAgent({ status: "terminated", lifecycleState, pauseReason: "setup" })); + await flushReact(); + expect(container.textContent).not.toContain("Finish setup"); + }); + + it("restores Run after lifecycle refresh completes setup without replacing the editable record", async () => { + const draft = makeAgent({ status: "paused", lifecycleState: "paused", lifecycleVersion: 1, pauseReason: "setup", pausedAt: new Date("2026-01-01T00:00:00Z"), adapterConfig: { model: "saved-model" } }); + queryClient.setQueryData(queryKeys.agents.detail("alpha"), draft); + mockAgentsApi.get.mockResolvedValueOnce(draft); + function LiveActions() { + const lifecycle = useAgentLifecycleStatus(draft); + return ; + } + root = createRoot(container); + root.render(); + await flushReact(); + expect(container.textContent).toContain("Finish setup"); + mockAgentsApi.get.mockResolvedValue({ ...draft, status: "idle", lifecycleState: "ready", lifecycleVersion: 2, pauseReason: null, pausedAt: null, adapterConfig: { model: "updated-on-server" } }); + await act(async () => { await queryClient.invalidateQueries({ queryKey: [...queryKeys.agents.detail(draft.id), "lifecycle", draft.companyId] }); }); + await flushReact(); + expect(container.textContent).not.toContain("Finish setup"); + const run = [...container.querySelectorAll("button")].find(button => button.textContent === "Run Heartbeat"); + expect(run?.disabled).toBe(false); + expect(queryClient.getQueryData(queryKeys.agents.detail("alpha"))?.adapterConfig).toEqual({ model: "saved-model" }); + expect(mockAgentsApi.get).toHaveBeenLastCalledWith("agent-1", "company-1"); + await act(() => run!.click()); + await flushReact(); + expect(mockAgentsApi.invoke).toHaveBeenCalledWith("agent-1", "company-1"); + }); + + it("checks unsaved changes before leaving to finish setup", async () => { + const onBeforeNavigate = vi.fn(() => false); + render(makeAgent({ status: "paused", lifecycleState: "paused", pauseReason: "setup" }), { hasPendingNavigationChanges: true, onBeforeNavigate }); + await flushReact(); + await act(() => [...container.querySelectorAll("button")].find(button => button.textContent === "Finish setup")!.click()); + expect(onBeforeNavigate).toHaveBeenCalledOnce(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockAgentsApi.invoke).not.toHaveBeenCalled(); + }); + + it("keeps setup available when the caller hides Run or blocks work", async () => { + render(makeAgent({ status: "paused", lifecycleState: "paused", pauseReason: "setup" }), { showRun: false, workActionsDisabled: true }); + await flushReact(); + expect([...container.querySelectorAll("button")].find(button => button.textContent === "Finish setup")!.disabled).toBe(false); + }); + + it("offers Run after setup completes without turning a manual pause into setup", async () => { + render(makeAgent({ status: "paused", lifecycleState: "paused", pauseReason: "setup" })); + await flushReact(); + render(makeAgent({ status: "paused", lifecycleState: "paused", pauseReason: "manual" })); + await flushReact(); + expect(container.textContent).toContain("Resume"); + expect(container.textContent).not.toContain("Finish setup"); + render(makeAgent({ status: "idle", lifecycleState: "ready", pauseReason: null })); + await flushReact(); + const run = [...container.querySelectorAll("button")].find(button => button.textContent === "Run Heartbeat"); + expect(run?.disabled).toBe(false); + await act(() => run!.click()); + await flushReact(); + expect(mockAgentsApi.invoke).toHaveBeenCalledWith("agent-1", "company-1"); + }); + it.each(["preparing", "verifying", "resuming"] as const)("offers pause rather than resume during %s", async lifecycleState => { render(makeAgent({ status: "paused", lifecycleState })); await flushReact(); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 6ee48a346e7..b24dc415b55 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -240,6 +240,8 @@ export function AgentActionButtons({ const canonicalAgentRef = agentRouteRef(agent); const isPaused = agent.lifecycleState ? agent.lifecycleState === "paused" : agent.status === "paused"; const isError = agent.status === "error"; + const needsSetup = agent.pauseReason === "setup" && agent.status !== "terminated" + && !["terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState ?? ""); const reportError = useCallback( (message: string) => { @@ -382,7 +384,16 @@ export function AgentActionButtons({ {assignLabel} - {showRun && { + if (!confirmNavigationStart(agentActionStartedDirtyRef)) return; + navigate(`/agents/new?${new URLSearchParams({ + name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id, + runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"), + ...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}), + })}`); + }}>Finish setup + ) : showRun && { if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return; agentAction.mutate("invoke"); @@ -391,18 +402,12 @@ export function AgentActionButtons({ label={runLabel} size={size} />} - {isError ? ( + {!needsSetup && (isError ? ( agentAction.mutate("clear_error")} disabled={clearErrorDisabled} size={size} /> - ) : agent.pauseReason === "setup" && agent.lifecycleState === "paused" ? ( - ) : ( - )} + ))} {pauseConfirm && ( diff --git a/ui/src/components/AgentLifecycleStatus.tsx b/ui/src/components/AgentLifecycleStatus.tsx index 689a54b8314..65998141514 100644 --- a/ui/src/components/AgentLifecycleStatus.tsx +++ b/ui/src/components/AgentLifecycleStatus.tsx @@ -35,7 +35,7 @@ export function useAgentLifecycleStatus(agent?: Agent) { queryKey: [...queryKeys.agents.detail(agent?.id ?? ""), "lifecycle", agent?.companyId], queryFn: () => agentsApi.get(agent!.id, agent!.companyId), enabled: Boolean(agent), - select: current => ({ status: current.status, lifecycleState: current.lifecycleState, + select: current => ({ status: current.status, pauseReason: current.pauseReason, pausedAt: current.pausedAt, lifecycleState: current.lifecycleState, lifecycleVersion: current.lifecycleVersion, lifecycleError: current.lifecycleError }), refetchInterval: query => agentLifecycleRefetchInterval(query.state.data?.lifecycleState ?? agent?.lifecycleState), });