From 75754ffb7af2b7ee7dce1e915a145faa82a8fb2a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 28 Oct 2026 11:30:24 -0700 Subject: [PATCH] fix: offer setup instead of Run for unfinished agents (#17976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - An agent can have a saved record before its setup is complete. > - This record stays paused with the setup reason and cannot run. > - Its shared action controls still offer Run beside Finish setup. > - Run then produces a generic refusal instead of taking the user to setup. > - This change makes Finish setup replace Run for that state. > - The user returns to the saved agent and can run it after setup completes. ## Linked Issues or Issue Description **What happened?** The header of a saved agent with unfinished setup offers Run. Clicking it produces "Agent is not invokable in its current state." Finish setup is a separate control that the user must discover. **Expected behavior** Show one Finish setup action in place of Run and Resume while the setup hold is present. Open the existing setup record with its selected runner. Restore normal controls when setup finishes. **Steps to reproduce** 1. Start creating an agent and save it before setup completes. 2. Open the agent from the roster to view its header. 3. Click Run and observe the generic refusal. Related: #17965 fixes the managed Runner connection-test result. This change fixes the controls for a saved setup record. No open duplicate was found in the title search. ## What Changed - Replace Run with one Finish setup action for a unfinished setup record. - Use the existing paused-state fallback when lifecycle details are absent. - Keep the saved agent ID and runner selection in the setup route. - Check unsaved changes before leaving the detail page. - Refresh pause reason and time with lifecycle state so completing setup in another tab restores Run without overwriting editable configuration. - Keep setup available when a caller hides Run or disables work actions. - Extend the agent action tests, onboarding browser journey and feature map. ## Verification - Red: three component regressions fail before the fix. The old UI keeps Run, lacks setup without lifecycle details, and skips the navigation guard. - Three more regressions fail before the preparation-state fix. Preparation, verification and pause transitions retain Finish setup; terminal states cannot reopen setup. - Green: 211 focused tests pass across agent actions, duplication, detail pages and saved setup. The final control suite passes 36 tests. - A real QueryClient, lifecycle hook and action-control regression fails before the pause-field refresh fix, then passes after it. The short-URL editable snapshot stays unchanged while the UUID lifecycle query restores Run. - UI typecheck and production build pass. Token and map checks pass. - The real Chromium onboarding journey passes on final source `67c4c59f6fbfc5bc8eca8f70e12b8dc1c3763f4f`: no Run or Resume, one Finish setup after reload, and the same saved agent reopens. The first attempt could not initialize the disposable PostgreSQL instance at the local shared-memory limit; no app assertion ran. After removing only a verified detached segment from this task’s completed browser run, the unchanged journey passes. No staging deployment is claimed. - A separate local integration branch combines this PR with the final write-recovery fixes from #17960. Its 36 action-control tests and the real onboarding browser journey pass too. This integration preview is not pushed to the PR branch. - All hosted CI gates pass on the final commit, including typecheck, tests, build and E2E. Apex is 5/5 with no unresolved findings. ## Risks - The change uses the existing setup pause reason through preparation and verification. Terminating and terminal records cannot reopen setup. Manual pauses retain Resume, and ready agents retain Run. - The API remains authoritative if state changes while the page is open. No admission, permission, account or credential rule changes. - The browser journey stops at setup configuration. It does not claim provider execution or deployment acceptance. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection, tool use and code execution. The session does not expose its exact serving model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- maps/feature-map/agents.md | 7 +- tests/e2e/onboarding.spec.ts | 6 ++ ui/src/components/AgentActionButtons.test.tsx | 83 ++++++++++++++++++- ui/src/components/AgentActionButtons.tsx | 23 +++-- ui/src/components/AgentLifecycleStatus.tsx | 2 +- 5 files changed, 108 insertions(+), 13 deletions(-) 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), });