fix: offer setup instead of Run for unfinished agents (#17976)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-28 11:30:24 -07:00
1 parent 0e365768f0
commit 75754ffb7a
5 files changed
+108 -13

No files matched your search

+5 -2
View File
@@ -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
+6
View File
@@ -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();
+82 -1
View File
@@ -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 <AgentActionButtons agent={{ ...draft, ...lifecycle.data }} companyId="company-1" runLabel="Run Heartbeat" />;
}
root = createRoot(container);
root.render(<QueryClientProvider client={queryClient}><LiveActions /></QueryClientProvider>);
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<Agent>(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();
+14 -9
View File
@@ -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({
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{assignLabel}</span>
</Button>
{showRun && <RunButton
{needsSetup ? (
<Button variant="outline" size={size} disabled={disabled} onClick={() => {
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</Button>
) : showRun && <RunButton
onClick={() => {
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
agentAction.mutate("invoke");
@@ -391,18 +402,12 @@ export function AgentActionButtons({
label={runLabel}
size={size}
/>}
{isError ? (
{!needsSetup && (isError ? (
<ClearErrorButton
onClick={() => agentAction.mutate("clear_error")}
disabled={clearErrorDisabled}
size={size}
/>
) : agent.pauseReason === "setup" && agent.lifecycleState === "paused" ? (
<Button variant="outline" size={size} disabled={disabled} onClick={() => 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</Button>
) : (
<PauseResumeButton
isPaused={isPaused}
@@ -411,7 +416,7 @@ export function AgentActionButtons({
disabled={pauseResumeDisabled}
size={size}
/>
)}
))}
{pauseConfirm && (
<AlertDialog open={pauseConfirmOpen} onOpenChange={setPauseConfirmOpen}>
<AlertDialogContent>
+1 -1
View File
@@ -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),
});