From fda42c5ab7fa0a69312f02651f27de9fa6b0c8b5 Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Mon, 26 Oct 2026 14:15:17 -0700
Subject: [PATCH] Keep setup tests aligned with saved configuration
Pass the measured Claude runner to connection tests, cancel stale detail reads before publishing mutation results, and retain environment-save errors across test resets. Move setup lifecycle cases into their own suite to keep the original file below the repository size threshold.
Verified 133 UI tests, 61 lifecycle and policy tests, UI TypeScript, server TypeScript pinned to this branch shared declarations, token gates, map checks, and whitespace. The new UI regressions fail against the previous implementation.
Co-Authored-By: Paperclip
---
maps/feature-map/agents.md | 2 +-
.../__tests__/lifecycle.test.ts | 117 -------------
.../agent-lifecycle/__tests__/setup.test.ts | 156 ++++++++++++++++++
ui/src/components/new-agent/NewAgentSetup.tsx | 17 +-
ui/src/pages/NewAgent.test.tsx | 37 +++++
5 files changed, 206 insertions(+), 123 deletions(-)
create mode 100644 server/src/modules/agent-lifecycle/__tests__/setup.test.ts
diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md
index 8215418dd41..c6e9c336570 100644
--- a/maps/feature-map/agents.md
+++ b/maps/feature-map/agents.md
@@ -99,7 +99,7 @@ For an agent-owned plugin environment, verify preparation before connecting,
same-agent probes, account preservation after an environment change, failed
selection-save retry, and switching back while the computer stops. Confirm the
setup hold still blocks ordinary work and API key creation before finishing.
-The [lifecycle tests](../../server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts)
+The [setup lifecycle tests](../../server/src/modules/agent-lifecycle/__tests__/setup.test.ts)
exercise durable transitions with synthetic plugin resources; they do not prove
live VM provisioning.
diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
index aa21b2e887a..d9b7dcf8e0c 100644
--- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
+++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
@@ -116,102 +116,8 @@ const support = await getEmbeddedPostgresTestSupport();
expect(agent).not.toHaveProperty("lifecycleOperation");
});
- it("keeps early setup inactive and completes the same record without renaming it", async () => {
- const runHost = vi.fn(async () => "complete" as const);
- const work = worker(runHost);
- const lifecycle = createAgentLifecycle(db);
- const id = randomUUID();
- const input = { id, name: "Early agent", adapterType: "process" };
- const first = await lifecycle.requestHire(companyId, input, { setupOnly: true });
- const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true });
- expect(first.id).toBe(id);
- expect(retry.id).toBe(id);
- await work.process(id);
- expect(runHost).not.toHaveBeenCalled();
- expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] });
- expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0);
- await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup");
- await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
- const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id });
- expect(finished).toMatchObject({ id, name: "Early agent" });
- await work.process(id);
- expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] });
- expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
- await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available");
- });
- it("prepares agent-dependent drafts, stays idle until finish, and invalidates changed environments", async () => {
- const pluginId = randomUUID(), environmentId = randomUUID();
- await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "agent-environment-test", version: "1.0.0", status: "ready",
- manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
- environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never });
- await db.insert(environments).values({ id: environmentId, name: "Agent-owned test computer", driver: "plugin", status: "active",
- config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
- const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
- const driver = createLifecycleDriver(db, { call } as never);
- const runHost = vi.fn(async () => "complete" as const);
- const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost });
- workers.push(work);
- const lifecycle = createAgentLifecycle(db);
- const id = randomUUID();
- const input = { id, name: "Early computer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId };
- const draft = await lifecycle.requestHire(companyId, input, { setupOnly: true, runnerResolved: true });
- expect(draft).toMatchObject({ lifecycleState: "preparing", status: "paused", pauseReason: "setup" });
- await work.process(id);
- expect(await current(id)).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleHolds: ["setup"] });
- expect(call.mock.calls.map(([, , request]) => request.phase)).toEqual(["preparing"]);
- const store = createLifecycleStore(db);
- expect(await store.pending(100, new Date())).not.toContain(id);
- expect(await store.claim(id, "unexpected", new Date())).toBeNull();
- await work.process(id);
- expect(runHost).toHaveBeenCalledTimes(1);
- await expect(agentConfiguration(db).createApiKey(id, "Incomplete computer")).rejects.toThrow("Finish agent setup");
- await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
- await lifecycle.pauseAgent(id, "budget"); await work.process(id);
- expect(await current(id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup", "budget"] });
- await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "changed" } });
- expect((await current(id)).lifecycleState).toBe("paused");
- await store.change(id, "reconcile"); await work.process(id);
- expect(await current(id)).toMatchObject({ lifecycleState: "verifying", lifecycleHolds: ["setup"] });
- await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "prepared" } });
- await work.process(id);
- expect((await current(id)).lifecycleState).toBe("verifying");
- const [local] = await db.select().from(environments).where(eq(environments.driver, "local"));
- await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); await work.process(id);
- expect((await current(id)).lifecycleState).toBe("paused");
- await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); await work.process(id);
- expect((await current(id)).lifecycleState).toBe("verifying");
- await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id });
- expect((await current(id)).lifecycleState).toBe("pausing");
- await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId });
- await work.process(id);
- expect((await current(id)).lifecycleState).toBe("verifying");
- const finished = await lifecycle.requestHire(companyId, input, { setupAgentId: id, runnerResolved: true });
- expect(finished.id).toBe(id);
- await work.process(id);
- expect(await current(id)).toMatchObject({ lifecycleState: "ready", lifecycleHolds: [] });
- expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying");
- });
- it("does not prepare agent-owned resources before required hire approval", async () => {
- const pluginId = randomUUID(), environmentId = randomUUID();
- await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
- await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "approval-computer-test", version: "1.0.0", status: "ready",
- manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
- environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never });
- await db.insert(environments).values({ id: environmentId, name: "Approval computer", driver: "plugin", status: "active",
- config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
- const runHost = vi.fn(async () => "complete" as const);
- const work = worker(runHost);
- const lifecycle = createAgentLifecycle(db);
- const draft = await lifecycle.requestHire(companyId, { name: "Unapproved computer", adapterType: "paperclip_runner",
- adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }, { setupOnly: true, runnerResolved: true });
- await work.process(draft.id);
- await lifecycle.updateConfiguration(draft.id, { adapterConfig: { provider: "codex", model: "changed" } });
- await work.process(draft.id);
- expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
- expect(runHost).not.toHaveBeenCalled();
- });
it("freezes the final setup configuration for hire approval", async () => {
const lifecycle = createAgentLifecycle(db);
@@ -233,29 +139,6 @@ const support = await getEmbeddedPostgresTestSupport();
expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]);
});
- it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => {
- await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
- principalType: "user", principalId, membershipRole: "member" })));
- await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
- principalType: "user", principalId, permissionKey: "agents:create" })));
- const lifecycle = createAgentLifecycle(db);
- const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" },
- { setupOnly: true, createdByUserId: "setup-owner" });
- await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id));
- function app(userId: string) {
- const value = express(); value.use(express.json());
- value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId,
- companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
- value.use("/api", agentRoutes(db)); value.use(errorHandler); return value;
- }
- const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } });
- expect(updated.status, JSON.stringify(updated.body)).toBe(200);
- await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200);
- await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403);
- await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403);
- await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available");
- expect((await current(draft.id)).lifecycleState).toBe("pausing");
- });
it("creates a board-owned setup before requesting approval for its final configuration", async () => {
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
diff --git a/server/src/modules/agent-lifecycle/__tests__/setup.test.ts b/server/src/modules/agent-lifecycle/__tests__/setup.test.ts
new file mode 100644
index 00000000000..fd558df179e
--- /dev/null
+++ b/server/src/modules/agent-lifecycle/__tests__/setup.test.ts
@@ -0,0 +1,156 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { eq } from "drizzle-orm";
+import { agents, companies, companyMemberships, principalPermissionGrants, environments, plugins, createDb, type Db } from "@paperclipai/db";
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
+import { createAgentLifecycle, configureAgentLifecycle, createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
+import { agentService as agentConfiguration } from "../../../services/agents.js";
+import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
+import { agentRoutes } from "../../../routes/agents.js";
+import { errorHandler } from "../../../middleware/error-handler.js";
+import { createLifecycleStore as createStore } from "../adapters/postgres.js";
+import type { LifecycleAgent } from "../application/ports.js";
+
+const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
+const support = await getEmbeddedPostgresTestSupport();
+(support.supported ? describe : describe.skip)("agent setup lifecycle", () => {
+ let database: Awaited>;
+ let db: Db;
+ let companyId: string;
+ const workers: ReturnType[] = [];
+ beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-setup-"); db = createDb(database.connectionString); }, 90_000);
+ afterAll(async () => { for (const work of workers) await work.stop(); await database?.cleanup(); });
+ beforeEach(async () => {
+ for (const work of workers) await work.stop();
+ companyId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Setup test", issuePrefix: `S${companyId.slice(0, 7)}` });
+ });
+ function worker(runHost: (agent: LifecycleAgent) => Promise<"complete" | "pending"> = async () => "complete") {
+ const work = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost });
+ workers.push(work); return work;
+ }
+ async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
+
+ it("keeps early setup inactive and completes the same record without renaming it", async () => {
+ const runHost = vi.fn(async () => "complete" as const);
+ const work = worker(runHost);
+ const lifecycle = createAgentLifecycle(db);
+ const id = randomUUID();
+ const input = { id, name: "Early agent", adapterType: "process" };
+ const first = await lifecycle.requestHire(companyId, input, { setupOnly: true });
+ const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true });
+ expect(first.id).toBe(id);
+ expect(retry.id).toBe(id);
+ await work.process(id);
+ expect(runHost).not.toHaveBeenCalled();
+ expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] });
+ expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0);
+ await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup");
+ await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
+ const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id });
+ expect(finished).toMatchObject({ id, name: "Early agent" });
+ await work.process(id);
+ expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] });
+ expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
+ await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available");
+ });
+
+ it("prepares agent-dependent drafts, stays idle until finish, and invalidates changed environments", async () => {
+ const pluginId = randomUUID(), environmentId = randomUUID();
+ await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "agent-environment-test", version: "1.0.0", status: "ready",
+ manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
+ environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never });
+ await db.insert(environments).values({ id: environmentId, name: "Agent-owned test computer", driver: "plugin", status: "active",
+ config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
+ const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
+ const driver = createLifecycleDriver(db, { call } as never);
+ const runHost = vi.fn(async () => "complete" as const);
+ const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost });
+ workers.push(work);
+ const lifecycle = createAgentLifecycle(db);
+ const id = randomUUID();
+ const input = { id, name: "Early computer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId };
+ const draft = await lifecycle.requestHire(companyId, input, { setupOnly: true, runnerResolved: true });
+ expect(draft).toMatchObject({ lifecycleState: "preparing", status: "paused", pauseReason: "setup" });
+ await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleHolds: ["setup"] });
+ expect(call.mock.calls.map(([, , request]) => request.phase)).toEqual(["preparing"]);
+ const store = createLifecycleStore(db);
+ expect(await store.pending(100, new Date())).not.toContain(id);
+ expect(await store.claim(id, "unexpected", new Date())).toBeNull();
+ await work.process(id);
+ expect(runHost).toHaveBeenCalledTimes(1);
+ await expect(agentConfiguration(db).createApiKey(id, "Incomplete computer")).rejects.toThrow("Finish agent setup");
+ await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
+ await lifecycle.pauseAgent(id, "budget"); await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup", "budget"] });
+ await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "changed" } });
+ expect((await current(id)).lifecycleState).toBe("paused");
+ await store.change(id, "reconcile"); await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "verifying", lifecycleHolds: ["setup"] });
+ await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "prepared" } });
+ await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("verifying");
+ const [local] = await db.select().from(environments).where(eq(environments.driver, "local"));
+ await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("paused");
+ await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("verifying");
+ await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id });
+ expect((await current(id)).lifecycleState).toBe("pausing");
+ await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId });
+ await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("verifying");
+ const finished = await lifecycle.requestHire(companyId, input, { setupAgentId: id, runnerResolved: true });
+ expect(finished.id).toBe(id);
+ await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "ready", lifecycleHolds: [] });
+ expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying");
+ });
+
+ it("does not prepare agent-owned resources before required hire approval", async () => {
+ const pluginId = randomUUID(), environmentId = randomUUID();
+ await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
+ await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "approval-computer-test", version: "1.0.0", status: "ready",
+ manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
+ environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never });
+ await db.insert(environments).values({ id: environmentId, name: "Approval computer", driver: "plugin", status: "active",
+ config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
+ const runHost = vi.fn(async () => "complete" as const);
+ const work = worker(runHost);
+ const lifecycle = createAgentLifecycle(db);
+ const draft = await lifecycle.requestHire(companyId, { name: "Unapproved computer", adapterType: "paperclip_runner",
+ adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }, { setupOnly: true, runnerResolved: true });
+ await work.process(draft.id);
+ await lifecycle.updateConfiguration(draft.id, { adapterConfig: { provider: "codex", model: "changed" } });
+ await work.process(draft.id);
+ expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
+ expect(runHost).not.toHaveBeenCalled();
+ });
+
+ it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => {
+ await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
+ principalType: "user", principalId, membershipRole: "member" })));
+ await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId,
+ principalType: "user", principalId, permissionKey: "agents:create" })));
+ const lifecycle = createAgentLifecycle(db);
+ const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" },
+ { setupOnly: true, createdByUserId: "setup-owner" });
+ await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id));
+ function app(userId: string) {
+ const value = express(); value.use(express.json());
+ value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId,
+ companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
+ value.use("/api", agentRoutes(db)); value.use(errorHandler); return value;
+ }
+ const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } });
+ expect(updated.status, JSON.stringify(updated.body)).toBe(200);
+ await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200);
+ await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403);
+ await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403);
+ await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available");
+ expect((await current(draft.id)).lifecycleState).toBe("pausing");
+ });
+});
diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx
index d74df5b6e54..7571b2d06ac 100644
--- a/ui/src/components/new-agent/NewAgentSetup.tsx
+++ b/ui/src/components/new-agent/NewAgentSetup.tsx
@@ -220,6 +220,7 @@ function Setup({
);
const [error, setError] = useState(null);
const [environmentSaving, setEnvironmentSaving] = useState(false);
+ const [environmentSaveError, setEnvironmentSaveError] = useState(null);
const generation = useRef(0);
const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null);
const savingRef = useRef(false);
@@ -571,7 +572,8 @@ function Setup({
...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
} : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
- }).then(agent => {
+ }).then(async agent => {
+ await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
setCreated(agent);
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
@@ -635,6 +637,7 @@ function Setup({
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
}, companyId);
rememberCredentials();
+ await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
setCreated(agent);
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
if (exit) navigate("/agents/all");
@@ -663,8 +666,9 @@ function Setup({
setEnvironmentOverride(selected);
resetTest();
setEnvironmentSaving(true);
+ setEnvironmentSaveError(null);
try { await saveDraft(connection, false, undefined, selected); }
- catch (cause) { setError(cause instanceof Error ? cause.message : "Could not save the environment."); }
+ catch (cause) { setEnvironmentSaveError(cause instanceof Error ? cause.message : "Could not save the environment."); }
finally { setEnvironmentSaving(false); }
}
@@ -695,7 +699,7 @@ function Setup({
agentId: created?.id,
adapterType: requestAdapterType,
providerAdapter: brandType,
- ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
+ ...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && (qualified || runner !== "auto") ? { runner: qualified?.runner ?? runner } : {}),
adapterConfig: config,
testCredentials: pendingCredentials(nextConnection),
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
@@ -766,6 +770,7 @@ function Setup({
});
setApiKey("");
setConnection(null);
+ await cache.cancelQueries({ queryKey: queryKeys.agents.detail(response.agent.id) });
setCreated(response.agent);
appearanceDraft.clear();
setScreen("saved");
@@ -902,7 +907,8 @@ function Setup({
{created.lifecycleError}
)}
- {error && !environmentSelectionSaved && }
+ {environmentSaveError && {environmentSaveError}
}
+ {environmentSaveError && !environmentSelectionSaved && }
diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx
index 0e1ed321a22..91fd659420f 100644
--- a/ui/src/pages/NewAgent.test.tsx
+++ b/ui/src/pages/NewAgent.test.tsx
@@ -41,12 +41,18 @@ describe("New agent setup", () => {
await click("Connect");
const originalBinding = api.testEnvironment.mock.calls[0][2].aiConnection;
const probes = api.testEnvironment.mock.calls.length;
+ let finishDiscovery!: (adapters: typeof state.adapters) => void;
+ adapterApi.list.mockImplementationOnce(() => new Promise(resolve => { finishDiscovery = resolve; }));
api.update.mockRejectedValueOnce(new Error("Environment save unavailable"));
const select = container.querySelector('[aria-label="Environment"]')!;
await act(async () => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await settle();
expect(container.textContent).toContain("Configure your agent");
expect(container.textContent).toContain("Environment save unavailable");
+ await act(async () => finishDiscovery(state.adapters.map((adapter: any) => adapter.type === "codex_local"
+ ? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter)));
+ await settle();
+ expect(container.textContent).toContain("Environment save unavailable");
await click("Run test");
expect(api.testEnvironment).toHaveBeenCalledTimes(probes);
await click("Retry saving environment");
@@ -56,6 +62,36 @@ describe("New agent setup", () => {
}));
});
+ it("ignores a detail read that completes after a newer environment save", async () => {
+ envApi.list.mockResolvedValue([
+ { id: "local-1", name: "Local", status: "active", driver: "local", config: {} },
+ { id: "computer", name: "Agent computer", status: "active", driver: "plugin", config: {} },
+ ]);
+ await render("codex_local");
+ await click("OpenAIAPI key");
+ await fill("API key", "setup-test-key");
+ await click("Connect");
+ const id = api.create.mock.calls[0][1].setupId;
+ state.params.set("setupAgentId", id);
+ await renderCurrentParams();
+ const key = queryKeys.agents.detail(id);
+ const oldDraft = cache.getQueryData(key);
+ let finishRead!: (agent: unknown) => void;
+ api.get.mockImplementationOnce(() => new Promise(resolve => { finishRead = resolve; }));
+ await act(async () => { void cache.refetchQueries({ queryKey: key }); });
+ expect(finishRead).toBeTypeOf("function");
+ const select = container.querySelector('[aria-label="Environment"]')!;
+ await act(async () => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); });
+ await settle();
+ await act(async () => finishRead(oldDraft));
+ await settle();
+ expect(cache.getQueryData(key)).toMatchObject({ defaultEnvironmentId: "computer" });
+ await click("Run test");
+ expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", expect.any(String), expect.objectContaining({ agentId: id, environmentId: "computer" }));
+ await click("Finish setup");
+ expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({ setupAgentId: id, defaultEnvironmentId: "computer" }));
+ });
+
it("waits for background preparation and tests the same saved computer after it completes", async () => {
const id = "00000000-0000-4000-8000-000000000092";
const draft = { id, companyId: "company-1", name: "Atlas", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" },
@@ -262,6 +298,7 @@ describe("New agent setup", () => {
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter"]);
expect(envApi.probe).toHaveBeenCalledExactlyOnceWith("sandbox-1", "company-1");
expect(api.testEnvironment).toHaveBeenCalledTimes(1);
+ expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({ agentId: api.create.mock.calls[0][1].setupId, runner: measured });
expect(api.hire).toHaveBeenCalledTimes(1);
const tested = api.testEnvironment.mock.calls[0][2].adapterConfig;
const saved = api.hire.mock.calls[0][1];