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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-10-26 14:30:41 -07:00
1 parent 260189d462
commit fda42c5ab7
5 files changed
+206 -123

No files matched your search

+1 -1
View File
@@ -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.
@@ -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));
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let companyId: string;
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
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");
});
});
+12 -5
View File
@@ -220,6 +220,7 @@ function Setup({
);
const [error, setError] = useState<string | null>(null);
const [environmentSaving, setEnvironmentSaving] = useState(false);
const [environmentSaveError, setEnvironmentSaveError] = useState<string | null>(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({
<p role="alert" className="text-sm text-destructive">{created.lifecycleError}</p>
<Button disabled={busy} onClick={() => {
setEnvironmentSaving(true);
void agentsApi.retryLifecycle(created.id, companyId).then(agent => {
void agentsApi.retryLifecycle(created.id, companyId).then(async agent => {
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
setCreated(agent);
cache.setQueryData(queryKeys.agents.detail(agent.id), agent);
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not retry computer preparation."))
@@ -1432,7 +1438,8 @@ function Setup({
{error}
</p>
)}
{error && !environmentSelectionSaved && <Button disabled={busy} onClick={() => void changeEnvironment()}>Retry saving environment</Button>}
{environmentSaveError && <p role="alert" className="text-sm text-destructive">{environmentSaveError}</p>}
{environmentSaveError && !environmentSelectionSaved && <Button disabled={busy} onClick={() => void changeEnvironment()}>Retry saving environment</Button>}
<div className="flex justify-between border-t border-border py-5">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" disabled={busy} onClick={() => void saveAndExit()}>Save & exit</Button>
+37
View File
@@ -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<HTMLSelectElement>('[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<HTMLSelectElement>('[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];