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),
});