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}

} + {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];