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];