mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat: offer existing Claude and Codex agents a runner upgrade (#17122)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Each agent stores its harness, model, account and runtime configuration. > - New Claude and Codex agents now use Paperclip Runner on qualified targets. > - Existing agents keep their legacy runner and need a clear upgrade action. > - The existing configuration API already translates settings and records revisions. > - This pull request adds an explicit upgrade action that uses that API. > - Operators can retain supported settings and correct rejected settings in place. ## Linked Issues or Issue Description **What existing behavior does this improve?** Existing Claude and Codex agent configuration. Related defaults: #15683 and #15993. **Current behavior** Existing agents retain their legacy runner. The operator must find the runner selector in Advanced to change it. **Proposed behavior** Show Upgrade runner when Paperclip Runner is the qualified default for the saved environment. One click saves pending edits and requests the existing server conversion. Show any rejected settings without changing saved configuration. **Reason and benefit** Operators can adopt the current runner while retaining their harness, model, account bindings, instructions and history. Unsupported settings remain visible and editable. **Breaking changes** None. Visiting configuration and saving unrelated changes do not upgrade an agent. The server API, authorization and conversion rules are unchanged. ## What Changed - Add an upgrade notice, pending state, saved result and retry feedback to the production configuration form. - Include pending profile, runtime and environment-variable edits in the upgrade request. - Bind asynchronous upgrade feedback and save receipts to their original agent. Navigating away cannot show success on another agent or redirect back. - Keep the notice absent for native agents, other harnesses, unavailable targets and failed discovery. - Add configuration/API regressions and database session-reset checks for Claude and Codex. - Add 13 production-page Storybook states with browser checks in both themes and on mobile. - Update the product contract and feature/access maps. ## Verification - Configuration form and original-agent save receipts: 135 tests passed. Existing agent route/rename helpers also pass. - Runner selection, configuration API and database migration regressions: 220 tests passed. - Storybook browser checks: 26 passed. Interactive cases wait for Storybook's visible Pass report. Static cases check the rendered production form. These use simulated HTTP boundaries. - Full typecheck, build, map checks and token gates passed on the current head. Storybook build and public preview verification passed. - Published fixture preview: https://storybook.paperclip.page/storybook/branches/codex~2Fagent-runner-upgrade/?path=/story/agents-runner-upgrade--legacy-codex - All 58 current-head PR checks passed, including the full test shards, browser suites, build and aggregate gates. The duplicate local pnpm test:run was stopped after 34 minutes once the same-head full CI suite passed; it had not finished its general-server phase. No local full-suite pass is claimed. - Real disposable local test drive: Codex used gpt-5.4 and Claude used claude-sonnet-4-6. Both completed one task on Legacy, upgraded through the browser button, and completed another task on Paperclip Runner with the same model and API credentials. Independent file checks confirmed saved instructions and environment bindings before and after conversion. - Reopened both configurations and compared identity, model, credential references, instruction hashes, working directories, run policies and configuration revisions. - A Claude Chrome setting produced a field-specific rejection. The saved agent stayed Legacy. Clearing Chrome in the form and clicking Upgrade runner saved the pending correction and completed conversion. ## Risks - The existing translator rejects settings that the new runner cannot honor. The notice shows that error and leaves the form available for correction. - Converted agents start fresh sessions. Prior runs remain available. - Real task execution was checked on local macOS with API credentials. SSH, sandbox targets and subscription credentials were not exercised in this change. - Storybook responses simulate persistence. The real test drive and database tests provide separate persistence evidence. ## Model Used - OpenAI GPT-6 through Codex. The exact serving variant and context-window size were not exposed in this session. Used reasoning, code editing, shell tools and browser interaction. No subagents. ## 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:
1 parent
13a83dd68a
commit
0f15f82b19
12 files changed
+478
-15
No files matched your search
@@ -49,6 +49,13 @@ start execution. This preference does not change the org chart or permissions.
|
||||
|
||||
### Agent Execution
|
||||
|
||||
Existing Claude and Codex agents can upgrade from the legacy runner on their
|
||||
Harness / Runtime page when Paperclip Runner is the qualified default for their
|
||||
environment. One action saves pending edits and translates compatible settings
|
||||
while keeping the harness, selected model, account bindings, instructions, and
|
||||
history. Unsupported settings produce an actionable error without changing the
|
||||
saved agent. Future runs start fresh sessions.
|
||||
|
||||
Paperclip supports several ways to run an agent's heartbeat:
|
||||
|
||||
1. **Local CLI/session adapters** — Paperclip starts or resumes local coding-tool sessions such as Claude Code, Codex, Gemini, OpenCode, Pi, and Cursor, then tracks the run.
|
||||
|
||||
@@ -1339,6 +1339,16 @@ Invites tab does not hide this Cloud action.
|
||||
|
||||
## 10.3 Agents
|
||||
|
||||
The Harness / Runtime configuration page offers **Upgrade runner** for existing
|
||||
legacy Claude and Codex agents when the target environment selects Paperclip
|
||||
Runner by default. The action uses the normal authorized agent PATCH with
|
||||
`runner: "paperclip"`, includes pending configuration edits, and retains the
|
||||
model and compatible settings. A rejected conversion leaves saved configuration
|
||||
unchanged and identifies unsupported fields. Successful conversion records a
|
||||
configuration revision, clears incompatible session state, and preserves agent
|
||||
identity, instruction files, credentials, run policy, and run history. Existing
|
||||
agents never upgrade merely by visiting or saving unrelated configuration.
|
||||
|
||||
The Core setup wizard creates its agent record when the user submits the agent
|
||||
name, before adapter selection, authentication, and runtime configuration.
|
||||
Until the adapter is chosen, the held record uses a provisional adapter
|
||||
|
||||
@@ -105,6 +105,7 @@ API: `present` — representative operation IDs:
|
||||
|
||||
- `GET /api/agents/{id}/instructions-bundle/file`.
|
||||
- `PUT /api/agents/{id}/instructions-bundle/file`.
|
||||
- `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade.
|
||||
|
||||
Agent access: `available` — Native read/update/history/restore instruction tools; REST /
|
||||
CLI.
|
||||
@@ -112,6 +113,12 @@ CLI.
|
||||
Policy: Agent creation/configuration uses action grants, resource policies and change
|
||||
consent; some controls require a board actor.
|
||||
|
||||
The existing-agent upgrade notice uses this configuration PATCH. It does not
|
||||
grant configuration, account, or environment access. The server resolves the
|
||||
saved settings under normal company/configuration authorization and rejects
|
||||
unsupported settings before persistence. Operator browser evidence does not
|
||||
qualify the agent permission paths for this operation.
|
||||
|
||||
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at
|
||||
the reviewed commit above.
|
||||
|
||||
|
||||
@@ -121,6 +121,25 @@ selecting another runner must not silently discard them or change an active run'
|
||||
recorded execution. Keep runner choice in Advanced and verify the next eligible
|
||||
run separately from historical transcripts.
|
||||
|
||||
Existing legacy Claude and Codex agents on qualified default environments also
|
||||
show **Upgrade runner** above their configuration. Exercise that action with
|
||||
pending edits and an environment-variable draft; reopen the page and compare the
|
||||
saved harness, model, account bindings, instructions, and run policy. Check a
|
||||
rejected incompatible setting, repair it, and retry. Verify a real task before
|
||||
and after migration, including its output, preserved history, and fresh session.
|
||||
The notice must stay absent for native agents, other harnesses, and unsupported
|
||||
targets. Discovery failure must not imply that an upgrade is available.
|
||||
Navigate to another agent while an upgrade is pending. Its result must stay with
|
||||
the original agent, without showing success on the new page or redirecting back.
|
||||
Focused coverage: [save receipt](../../ui/src/pages/AgentRunnerUpgrade.test.tsx)
|
||||
and [configuration form](../../ui/src/components/AgentConfigForm.render.test.tsx).
|
||||
|
||||
Storybook: [Runner upgrade](../../ui/storybook/stories/agents/codex-runner/upgrade.stories.tsx)
|
||||
mounts the production page and shell with mutable HTTP fixtures for both harnesses,
|
||||
pending, failure/retry, incompatible settings, repair, native/unsupported states,
|
||||
and mobile/light presentation. Fixture success does not establish real migration
|
||||
or provider execution.
|
||||
|
||||
### `organization`
|
||||
|
||||
Automated: [org chart](../../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../../ui/src/components/ConfigureBuiltInAgentModal.test.tsx).
|
||||
|
||||
@@ -941,6 +941,43 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).toMatchObject({ adapterConfig: { provider: "codex" } });
|
||||
});
|
||||
|
||||
it.each(["codex_local", "claude_local"])("upgrades an existing %s through runner selection with its saved settings", async adapterType => {
|
||||
const existing = await mockAgentService.getById();
|
||||
const env = { MIGRATION_MARKER: { type: "plain", value: "keep" } };
|
||||
const model = adapterType === "claude_local" ? "claude-sonnet-4-6" : "gpt-5.4";
|
||||
mockAgentService.getById.mockResolvedValue({ ...existing, adapterType,
|
||||
adapterConfig: { model, effort: "medium", env, promptTemplate: "Keep these instructions", timeoutSec: 180 },
|
||||
});
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runner: "paperclip" }));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ model, modelReasoningEffort: "medium", env,
|
||||
promptTemplate: "Keep these instructions", timeoutSec: 180,
|
||||
...(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude" } : { provider: "codex" }),
|
||||
}),
|
||||
}), expect.objectContaining({ recordRevision: expect.objectContaining({ source: "patch" }) }));
|
||||
const patch = mockAgentService.update.mock.calls[0][1];
|
||||
expect(patch).not.toHaveProperty("runner");
|
||||
expect(patch.adapterConfig).not.toHaveProperty("effort");
|
||||
expect(patch).not.toHaveProperty("runtimeConfig");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated", entityId: existing.id }));
|
||||
});
|
||||
|
||||
it("leaves a legacy agent unchanged when upgrade settings are incompatible", async () => {
|
||||
const existing = await mockAgentService.getById();
|
||||
mockAgentService.getById.mockResolvedValue({ ...existing, adapterConfig: { model: "gpt-5.4", extraArgs: ["--search"] } });
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runner: "paperclip" }));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.details).toMatchObject({ code: "agent_runner_config_incompatible", fields: ["extraArgs"] });
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
const existing = await mockAgentService.getById();
|
||||
|
||||
@@ -47,18 +47,20 @@ describeEmbeddedPostgres("agent service clearError", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("resets converted agent sessions while preserving identity, configuration and run history", async () => {
|
||||
it.each(["claude_local", "codex_local"])("resets converted %s sessions while preserving identity, configuration and run history", async adapterType => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const config = { cwd: "/tmp/runner-conversion", instructionsFilePath: "/tmp/runner-conversion/AGENTS.md", env: { TEST_KEY: { type: "plain", value: "kept" } } };
|
||||
const model = adapterType === "claude_local" ? "claude-sonnet-4-6" : "gpt-5.4";
|
||||
const config = { model, cwd: "/tmp/runner-conversion", instructionsFilePath: "/tmp/runner-conversion/AGENTS.md", env: { TEST_KEY: { type: "plain", value: "kept" } } };
|
||||
await db.insert(companies).values({ id: companyId, name: "Conversion", issuePrefix: `T${companyId.slice(0, 6).toUpperCase()}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Claude QA", role: "engineer", adapterType: "claude_local", adapterConfig: config });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Migration QA", role: "engineer", adapterType, adapterConfig: config });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", status: "succeeded", resultJson: { summary: "history stays" } });
|
||||
await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "claude_local", taskKey: "issue:test", sessionDisplayId: "old-session", lastRunId: runId });
|
||||
await db.insert(agentRuntimeState).values({ companyId, agentId, adapterType: "claude_local", sessionId: "old-session", stateJson: { old: true }, lastRunId: runId });
|
||||
const updated = await agentService(db).update(agentId, { adapterType: "paperclip_runner", adapterConfig: { ...config, provider: "acpx", acpxAgent: "claude", model: "custom-claude-model" } });
|
||||
expect(updated).toMatchObject({ id: agentId, companyId, name: "Claude QA", role: "engineer", adapterConfig: config });
|
||||
await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType, taskKey: "issue:test", sessionDisplayId: "old-session", lastRunId: runId });
|
||||
await db.insert(agentRuntimeState).values({ companyId, agentId, adapterType, sessionId: "old-session", stateJson: { old: true }, lastRunId: runId });
|
||||
const profile = adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude" } : { provider: "codex" };
|
||||
const updated = await agentService(db).update(agentId, { adapterType: "paperclip_runner", adapterConfig: { ...config, ...profile } });
|
||||
expect(updated).toMatchObject({ id: agentId, companyId, name: "Migration QA", role: "engineer", adapterConfig: config });
|
||||
expect(await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, agentId))).toEqual([]);
|
||||
const [runtime] = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, agentId));
|
||||
expect(runtime).toMatchObject({ adapterType: "paperclip_runner", sessionId: null, stateJson: {}, lastRunId: runId });
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const stories = ["legacy-codex", "legacy-claude", "upgrade-codex", "upgrade-claude", "upgrading",
|
||||
"failure-and-retry", "incompatible-claude-setting", "repair-and-upgrade", "already-upgraded",
|
||||
"unavailable-environment", "agent-running", "mobile", "light"];
|
||||
|
||||
for (const theme of ["dark", "light"]) {
|
||||
for (const story of stories) {
|
||||
test(`${story} [${theme}]`, async ({ page }, testInfo) => {
|
||||
if (story === "mobile") await page.setViewportSize({ width: 390, height: 844 });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const interactive = ["upgrade-codex", "upgrade-claude", "upgrading", "failure-and-retry", "incompatible-claude-setting", "repair-and-upgrade"].includes(story);
|
||||
await page.goto(interactive
|
||||
? `/?path=/story/agents-runner-upgrade--${story}&globals=theme:${theme}`
|
||||
: `/iframe.html?id=agents-runner-upgrade--${story}&viewMode=story&globals=theme:${theme}`);
|
||||
const main = interactive ? page.frameLocator("#storybook-preview-iframe").getByRole("main") : page.getByRole("main");
|
||||
await expect(main.getByRole("button", { name: "Model", exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
if (interactive) {
|
||||
await page.getByRole("tab", { name: /^Interactions/ }).click();
|
||||
await expect(page.getByRole("tabpanel", { name: /^Interactions/ }).getByText("Pass", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
if (["upgrade-codex", "upgrade-claude", "failure-and-retry", "repair-and-upgrade"].includes(story)) {
|
||||
await expect(main.getByText("Upgraded to Paperclip Runner.", { exact: true })).toBeVisible();
|
||||
await expect(main.getByRole("button", { name: "Upgrade runner", exact: true })).toHaveCount(0);
|
||||
} else if (["already-upgraded", "unavailable-environment"].includes(story)) {
|
||||
await expect(main.getByText("Upgrade to Paperclip Runner", { exact: true })).toHaveCount(0);
|
||||
} else if (story === "upgrading") {
|
||||
await expect(main.getByRole("button", { name: "Upgrading…", exact: true })).toBeDisabled();
|
||||
} else {
|
||||
await expect(main.getByRole("button", { name: "Upgrade runner", exact: true })).toBeEnabled();
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
const path = testInfo.outputPath(`${story}-${theme}.png`);
|
||||
if (interactive) await page.locator("#storybook-preview-iframe").screenshot({ path });
|
||||
else await page.screenshot({ path, fullPage: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -717,6 +717,83 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" });
|
||||
});
|
||||
|
||||
it.each(["codex_local", "claude_local"])("upgrades a saved %s in one click with pending edits", async adapterType => {
|
||||
mockAdaptersApi.list.mockResolvedValueOnce([{ type: adapterType, supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
|
||||
const result = await renderForm([], { adapterType, adapterConfig: { model: "existing-model", effort: "high" } });
|
||||
roots.push(result.root);
|
||||
expect(result.container.textContent).toContain("Upgrade to Paperclip Runner");
|
||||
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed agent"));
|
||||
await flushReact();
|
||||
await clickByText(result.container, "Upgrade runner");
|
||||
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed agent", runner: "paperclip" });
|
||||
});
|
||||
|
||||
it.each(["paperclip_runner", "gemini_local"])("does not offer an upgrade for %s", async adapterType => {
|
||||
const result = await renderForm([], { adapterType });
|
||||
roots.push(result.root);
|
||||
expect(result.container.textContent).not.toContain("Upgrade runner");
|
||||
});
|
||||
|
||||
it("includes an uncommitted environment variable draft in an upgrade", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
|
||||
const result = await renderForm([], { adapterConfig: { model: "gpt-5.4" } }, { environmentVariablesPlacement: "configuration" });
|
||||
roots.push(result.root);
|
||||
await clickByText(result.container, "Add variable");
|
||||
await act(async () => {
|
||||
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!, "MIGRATION_MARKER");
|
||||
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!, "kept");
|
||||
});
|
||||
await flushReact();
|
||||
await clickByText(result.container, "Upgrade runner");
|
||||
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ runner: "paperclip", replaceAdapterConfig: true,
|
||||
adapterConfig: { model: "gpt-5.4", env: { MIGRATION_MARKER: { type: "plain", value: "kept" } } },
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps failed upgrades editable and allows retry", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
|
||||
const result = await renderForm([]);
|
||||
roots.push(result.root);
|
||||
result.onSave.mockRejectedValueOnce(new Error("Paperclip Runner cannot honor these settings: extraArgs."));
|
||||
await clickByText(result.container, "Upgrade runner");
|
||||
expect(result.container.querySelector('[role="alert"]')?.textContent).toContain("extraArgs");
|
||||
expect(result.onSave).toHaveBeenLastCalledWith({ runner: "paperclip" });
|
||||
await clickByText(result.container, "Upgrade runner");
|
||||
expect(result.onSave).toHaveBeenCalledTimes(2);
|
||||
expect(result.container.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show an earlier agent's upgrade result after navigation", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
|
||||
let finishUpgrade!: () => void;
|
||||
let changeAgent!: () => void;
|
||||
const save = vi.fn(() => new Promise<void>(resolve => { finishUpgrade = resolve; }));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
function Fixture() {
|
||||
const [agent, setAgent] = useState(() => makeAgent());
|
||||
changeAgent = () => setAgent(makeAgent({ id: "agent-2", name: "Another agent" }));
|
||||
return <AgentConfigForm mode="edit" agent={agent} onSave={save} hidePromptTemplate />;
|
||||
}
|
||||
await act(async () => renderFormView(root, createFormQueryClient(), <Fixture />));
|
||||
await flushReact();
|
||||
await clickByText(container, "Upgrade runner");
|
||||
await act(async () => changeAgent());
|
||||
await act(async () => finishUpgrade());
|
||||
await flushReact();
|
||||
expect(container.textContent).not.toContain("Upgraded to Paperclip Runner.");
|
||||
expect(findButton(container, "Upgrade runner")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not offer an upgrade when the target only supports Legacy", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy"], defaultRunner: "legacy" }]);
|
||||
const result = await renderForm([]);
|
||||
roots.push(result.root);
|
||||
expect(result.container.textContent).not.toContain("Upgrade runner");
|
||||
});
|
||||
|
||||
it.each(["codex", "claude"].flatMap(harness => ["modelReasoningEffort", "reasoningEffort", "effort"].map(key => ({ harness, key }))))("hydrates saved effort and clears explicit Auto for $harness ($key)", async ({ harness, key }) => {
|
||||
const profile = harness === "codex" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" };
|
||||
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { ...profile, [key]: "high" } });
|
||||
|
||||
@@ -475,6 +475,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
|
||||
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
|
||||
const [environmentEditorKey, setEnvironmentEditorKey] = useState(0);
|
||||
const [runnerUpgrade, setRunnerUpgrade] = useState<{
|
||||
agentId: string;
|
||||
status: "pending" | "upgraded" | "failed";
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const currentRunnerUpgrade = !isCreate && runnerUpgrade?.agentId === props.agent.id ? runnerUpgrade : null;
|
||||
const runnerUpgradePending = currentRunnerUpgrade?.status === "pending";
|
||||
const runnerUpgraded = currentRunnerUpgrade?.status === "upgraded";
|
||||
const runnerUpgradeError = currentRunnerUpgrade?.error;
|
||||
useEffect(() => {
|
||||
if (!isCreate && props.agent.adapterType !== "paperclip_runner") {
|
||||
setRunnerUpgrade(current => current?.agentId === props.agent.id ? null : current);
|
||||
}
|
||||
}, [isCreate, !isCreate ? props.agent.id : undefined, !isCreate ? props.agent.adapterType : undefined]);
|
||||
const agentRef = useRef<Agent | null>(null);
|
||||
// The overlay snapshot a background account-binding save persisted. The form
|
||||
// stays editable while that save is in flight, so the agent refresh that
|
||||
@@ -623,6 +637,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType);
|
||||
const showRunnerUpgrade = !isCreate
|
||||
&& ["codex_local", "claude_local"].includes(props.agent.adapterType)
|
||||
&& adapterType === props.agent.adapterType
|
||||
&& !runnerDiscovery.isError
|
||||
&& runnerAvailability?.defaultRunner === "paperclip";
|
||||
const upgradeRunner = async () => {
|
||||
if (isCreate || runnerUpgradePending) return;
|
||||
const agentId = props.agent.id;
|
||||
setRunnerUpgrade({ agentId, status: "pending" });
|
||||
try {
|
||||
const flushedEnv = flushEnvironmentDraft();
|
||||
const nextOverlay = flushedEnv
|
||||
? { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: flushedEnv } }
|
||||
: overlay;
|
||||
await props.onSave({ ...buildAgentUpdatePatch(props.agent, nextOverlay), runner: "paperclip" });
|
||||
setRunnerUpgrade(current => current?.agentId === agentId ? { agentId, status: "upgraded" } : current);
|
||||
} catch (error) {
|
||||
setRunnerUpgrade(current => current?.agentId === agentId ? {
|
||||
agentId, status: "failed", error: error instanceof Error ? error.message : "Could not upgrade this agent. Try again.",
|
||||
} : current);
|
||||
}
|
||||
};
|
||||
const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType);
|
||||
const fieldsAdapterType = isRunnerHarness
|
||||
? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType)
|
||||
@@ -1487,6 +1523,27 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showRunnerUpgrade && !runnerUpgraded ? (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">Upgrade to Paperclip Runner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keep {harnessType === "claude_local" ? "Claude" : "Codex"}, your model, account, and compatible settings.
|
||||
New sessions start fresh; past runs stay available.
|
||||
</p>
|
||||
{isDirty ? <p className="text-xs text-muted-foreground">Your unsaved changes will be included.</p> : null}
|
||||
</div>
|
||||
<Button size="sm" className="self-end sm:self-auto" onClick={upgradeRunner} disabled={runnerUpgradePending || (!isCreate && props.isSaving)}>
|
||||
{runnerUpgradePending ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{runnerUpgradePending ? "Upgrading…" : "Upgrade runner"}
|
||||
</Button>
|
||||
</div>
|
||||
{runnerUpgradeError ? <p role="alert" className="text-sm text-destructive">{runnerUpgradeError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{runnerUpgraded ? <p role="status" className="text-sm text-muted-foreground">Upgraded to Paperclip Runner.</p> : null}
|
||||
<ConfigSections order={props.sectionOrder} className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
{isDirty && !props.hideInlineSave && (
|
||||
@@ -2240,6 +2297,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
/>
|
||||
)}
|
||||
</ConfigSections>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2048,16 +2048,18 @@ export function ConfigurationTab({
|
||||
});
|
||||
|
||||
const updateAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||
mutationFn: ({ target, patch }: { target: AgentDetailRecord; patch: Record<string, unknown> }) =>
|
||||
agentsApi.update(target.id, patch, target.companyId),
|
||||
onMutate: () => {
|
||||
setAwaitingRefreshAfterSave(true);
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
|
||||
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? content)) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
onSuccess: (updated, { target }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(target.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(target.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(target.companyId) });
|
||||
if (lastAgentRef.current.id !== target.id
|
||||
|| !syncAgentRouteAfterRename(queryClient, navigate, target, updated, urlTab ?? content)) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(target.urlKey) });
|
||||
}
|
||||
pushToast({ title: "Agent saved", tone: "success" });
|
||||
},
|
||||
@@ -2113,7 +2115,7 @@ export function ConfigurationTab({
|
||||
{content !== "permissions" ? <AgentConfigForm
|
||||
mode="edit"
|
||||
agent={agent}
|
||||
onSave={(patch) => updateAgent.mutateAsync(patch)}
|
||||
onSave={(patch) => updateAgent.mutateAsync({ target: agent, patch })}
|
||||
isSaving={isConfigSaving}
|
||||
adapterModels={adapterModels}
|
||||
onDirtyChange={onDirtyChange}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { AgentDetail } from "@paperclipai/shared";
|
||||
import { ConfigurationTab } from "./AgentDetail";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ update: vi.fn(), navigate: vi.fn(), toast: vi.fn() }));
|
||||
vi.mock("../api/agents", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../api/agents")>();
|
||||
return { ...actual, agentsApi: { ...actual.agentsApi, update: mocks.update, adapterModels: async () => [] } };
|
||||
});
|
||||
vi.mock("@/lib/router", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("@/lib/router")>();
|
||||
return { ...actual, useNavigate: () => mocks.navigate, useParams: () => ({ tab: "runtime" }) };
|
||||
});
|
||||
vi.mock("../context/ToastContext", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../context/ToastContext")>();
|
||||
return { ...actual, useToastActions: () => ({ pushToast: mocks.toast }) };
|
||||
});
|
||||
vi.mock("../components/AgentConfigForm", () => ({
|
||||
AgentConfigForm: ({ onSave }: { onSave: (patch: Record<string, unknown>) => Promise<unknown> }) =>
|
||||
<button onClick={() => { void onSave({ runner: "paperclip" }); }}>Upgrade runner</button>,
|
||||
}));
|
||||
|
||||
function agent(id: string): AgentDetail {
|
||||
return { id, companyId: "company-1", name: id, urlKey: id, role: "engineer", permissions: {},
|
||||
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, chainOfCommand: [] } as unknown as AgentDetail;
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("keeps an upgrade receipt on its original agent when navigation changes the configuration page", async () => {
|
||||
const original = agent("original-agent");
|
||||
let finish!: (updated: AgentDetail) => void;
|
||||
mocks.update.mockReturnValue(new Promise<AgentDetail>(resolve => { finish = resolve; }));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const noop = () => {};
|
||||
const render = (value: AgentDetail) => root.render(<QueryClientProvider client={client}>
|
||||
<ConfigurationTab agent={value} companyId={value.companyId} onDirtyChange={noop} onSaveActionChange={noop}
|
||||
onCancelActionChange={noop} onSavingChange={noop} updatePermissions={{ mutate: noop, isPending: false }} />
|
||||
</QueryClientProvider>);
|
||||
try {
|
||||
await act(async () => render(original));
|
||||
await act(async () => container.querySelector("button")!.click());
|
||||
await act(async () => render(agent("other-agent")));
|
||||
await act(async () => finish({ ...original, adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
expect(mocks.update).toHaveBeenCalledWith(original.id, { runner: "paperclip" }, original.companyId);
|
||||
expect(mocks.navigate).not.toHaveBeenCalled();
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["agents", "detail", original.id] });
|
||||
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ["agents", "detail", "other-agent"] });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { Route, Routes } from "@/lib/router";
|
||||
import { AgentDetail } from "@/pages/AgentDetail";
|
||||
import { Layout } from "@/components/Layout.production";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { storybookAgents } from "../../../fixtures/paperclipData";
|
||||
import { resetOnboardingFixtureState, setOnboardingFixtureState } from "../../../fixtures/onboardingEnvironment";
|
||||
|
||||
type Scenario = {
|
||||
harness?: "codex_local" | "claude_local";
|
||||
failOnce?: boolean;
|
||||
incompatible?: boolean;
|
||||
unavailable?: boolean;
|
||||
upgraded?: boolean;
|
||||
running?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
function installUpgradeFixture({ harness = "codex_local", failOnce = false, incompatible = false, unavailable = false, upgraded = false, running = false, delay = 0 }: Scenario = {}) {
|
||||
resetOnboardingFixtureState();
|
||||
setOnboardingFixtureState({ environments: "local", authSignal: "present", localLoginStatus: "ready" });
|
||||
const profile = harness === "claude_local" ? { provider: "acpx", acpxAgent: "claude" } : { provider: "codex" };
|
||||
let agent = { ...storybookAgents[0]!, urlKey: "migration-agent", name: harness === "claude_local" ? "ClaudeWriter" : "CodexCoder",
|
||||
status: running ? "running" : "idle", lifecycleState: "ready", defaultEnvironmentId: null,
|
||||
adapterType: upgraded ? "paperclip_runner" : harness,
|
||||
adapterConfig: { model: harness === "claude_local" ? "claude-sonnet-4-6" : "gpt-5.4", modelReasoningEffort: "medium", timeoutSec: 180,
|
||||
...(upgraded ? profile : {}), ...(incompatible ? { chrome: true } : {}), env: { MIGRATION_MARKER: { type: "plain", value: "keep-this-setting" } } },
|
||||
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
|
||||
chainOfCommand: [], access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] },
|
||||
};
|
||||
const previous = window.fetch;
|
||||
const fixtureFetch: typeof fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
|
||||
const method = init?.method ?? "GET";
|
||||
if (url.pathname === "/api/adapters") {
|
||||
const response = await previous(input, init);
|
||||
return Response.json((await response.json()).map((item: { type: string }) => ["codex_local", "claude_local"].includes(item.type)
|
||||
? { ...item, defaultRunner: unavailable ? "legacy" : "paperclip", supportedRunners: unavailable ? ["legacy"] : ["legacy", "paperclip"] } : item));
|
||||
}
|
||||
if (url.pathname === `/api/companies/${agent.companyId}/agents`) {
|
||||
return Response.json(storybookAgents.map(item => item.id === agent.id ? agent : item));
|
||||
}
|
||||
if ([`/api/agents/${agent.id}`, `/api/agents/${agent.urlKey}`].includes(url.pathname)) {
|
||||
if (method === "PATCH") {
|
||||
const patch = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (delay) await new Promise(resolve => setTimeout(resolve, delay));
|
||||
if (patch.runner !== "paperclip") return Response.json({ error: "This fixture expects an explicit runner upgrade." }, { status: 422 });
|
||||
if (failOnce) {
|
||||
failOnce = false;
|
||||
return Response.json({ error: "Could not save this agent. Try again." }, { status: 503 });
|
||||
}
|
||||
if (incompatible && (patch.adapterConfig?.chrome ?? agent.adapterConfig.chrome) === true) return Response.json({ error: "Paperclip Runner cannot honor these settings: chrome. Remove them or select Legacy runner in Advanced." }, { status: 422 });
|
||||
const { runner: _runner, replaceAdapterConfig: _replace, adapterConfig, ...rest } = patch;
|
||||
agent = { ...agent, ...rest, adapterType: "paperclip_runner", adapterConfig: { ...agent.adapterConfig, ...adapterConfig, ...profile } };
|
||||
}
|
||||
return Response.json(agent);
|
||||
}
|
||||
return previous(input, init);
|
||||
};
|
||||
window.fetch = fixtureFetch;
|
||||
return () => { if (window.fetch === fixtureFetch) window.fetch = previous; resetOnboardingFixtureState(); };
|
||||
}
|
||||
|
||||
function UpgradePage() {
|
||||
return <PluginLauncherProvider><Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="agents/:agentId/:tab?" element={<AgentDetail />} />
|
||||
</Route>
|
||||
</Routes></PluginLauncherProvider>;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "agents-runner-upgrade",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Runner upgrade",
|
||||
component: UpgradePage,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
initialEntries: ["/PAP/agents/migration-agent/runtime"],
|
||||
docs: { description: { component: "Existing Codex and Claude agents in the production agent page and app shell. The upgrade PATCH and subsequent reads use a mutable HTTP fixture. These stories demonstrate the notice, save, retry and refreshed form; real migration and provider proof are separate checks." } },
|
||||
paperclip: {
|
||||
capabilities: ["agents/instructions", "runs-adapters/validation"],
|
||||
status: "current",
|
||||
implementation: "production",
|
||||
boundary: "fixtures",
|
||||
notes: "Production AgentDetail, ConfigurationTab, AgentConfigForm and app shell. Mutable fixtures simulate upgrade persistence and rejection; no provider, credential access, session reset or durable database is exercised.",
|
||||
},
|
||||
},
|
||||
beforeEach: ({ parameters }) => installUpgradeFixture(parameters.runnerUpgrade),
|
||||
render: () => <UpgradePage />,
|
||||
} satisfies Meta<typeof UpgradePage>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const LegacyCodex: Story = {};
|
||||
export const LegacyClaude: Story = { parameters: { runnerUpgrade: { harness: "claude_local" } } };
|
||||
export const Upgrading: Story = { parameters: { runnerUpgrade: { delay: 60_000 } }, play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await userEvent.click(await page.findByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByRole("button", { name: "Upgrading…" })).toBeDisabled();
|
||||
} };
|
||||
export const UpgradeCodex: Story = { play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await userEvent.click(await page.findByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();
|
||||
await waitFor(() => expect(page.queryByRole("button", { name: "Upgrade runner" })).not.toBeInTheDocument());
|
||||
await expect(await page.findByRole("button", { name: /^Model$/ })).toHaveTextContent(/gpt-5.4/i);
|
||||
} };
|
||||
export const UpgradeClaude: Story = { ...UpgradeCodex, parameters: { runnerUpgrade: { harness: "claude_local" } }, play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await userEvent.click(await page.findByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();
|
||||
await expect(await page.findByRole("button", { name: /^Model$/ })).toHaveTextContent(/claude[- ]sonnet[- ]4[-.]6/i);
|
||||
} };
|
||||
export const FailureAndRetry: Story = { parameters: { runnerUpgrade: { failOnce: true } }, play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await userEvent.click(await page.findByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByRole("alert")).toHaveTextContent("Could not save this agent");
|
||||
await userEvent.click(page.getByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();
|
||||
} };
|
||||
export const IncompatibleClaudeSetting: Story = { parameters: { runnerUpgrade: { harness: "claude_local", incompatible: true } }, play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await userEvent.click(await page.findByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByRole("alert")).toHaveTextContent("chrome");
|
||||
await expect(page.getByRole("switch", { name: "Enable Chrome" })).toBeChecked();
|
||||
} };
|
||||
export const RepairAndUpgrade: Story = { ...IncompatibleClaudeSetting, play: async context => {
|
||||
await IncompatibleClaudeSetting.play!(context);
|
||||
const page = within(context.canvasElement);
|
||||
await userEvent.click(page.getByRole("switch", { name: "Enable Chrome" }));
|
||||
await expect(page.getByText("Your unsaved changes will be included.")).toBeVisible();
|
||||
await userEvent.click(page.getByRole("button", { name: "Upgrade runner" }));
|
||||
await expect(await page.findByText("Upgraded to Paperclip Runner.")).toBeVisible();
|
||||
} };
|
||||
export const AlreadyUpgraded: Story = { parameters: { runnerUpgrade: { upgraded: true } } };
|
||||
export const UnavailableEnvironment: Story = { parameters: { runnerUpgrade: { unavailable: true } } };
|
||||
export const AgentRunning: Story = { parameters: { runnerUpgrade: { running: true } } };
|
||||
export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
Reference in new issue
Block a user