diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 7e6d9bfc3ce..1aa12179772 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -67,7 +67,7 @@ const mockApprovalService = vi.hoisted(() => ({ const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(async () => ({ defaultEnvironmentId: null })), getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), - getExperimental: vi.fn(async () => ({ enableNativeRunner: false })), + getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })), })); const mockManagedAgentProfileService = vi.hoisted(() => ({ @@ -673,6 +673,7 @@ describe("agent routes adapter validation", () => { }); it("rejects a new paperclip_runner selection while the rollout flag is off", async () => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); const app = await createApp(); const res = await requestApp(app, (baseUrl) => request(baseUrl) @@ -737,19 +738,45 @@ describe("agent routes adapter validation", () => { expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ dotAttachmentAccess: true, dotWorkspaceAccess: false }) }), expect.anything()); }); - it.each(["create", "convert"])("saves an unpaired Dot configuration for %s while refusing task admission", async mode => { - mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true }); + it.each(["create", "hire", "convert"])("saves an unpaired Dot configuration independently of the Runner rollout for %s while refusing task admission", async mode => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); const app = await createApp(); const config = { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true }; const response = await requestApp(app, baseUrl => mode === "create" ? request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Dot", adapterType: "paperclip_runner", adapterConfig: config }) + : mode === "hire" ? request(baseUrl).post("/api/companies/company-1/agent-hires").send({ name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: config }) : request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterType: "paperclip_runner", replaceAdapterConfig: true, adapterConfig: config })); - expect(response.status, JSON.stringify(response.body)).toBe(mode === "create" ? 201 : 200); - expect(response.body.adapterConfig).toMatchObject(config); - expect(response.body.adapterConfig.model).toBeUndefined(); - expect(response.body.adapterConfig.codexPermissionMode).toBeUndefined(); + expect(response.status, JSON.stringify(response.body)).toBe(mode === "convert" ? 200 : 201); + const saved = mode === "hire" ? response.body.agent : response.body; + expect(saved.adapterConfig).toMatchObject(config); + expect(saved.adapterConfig.model).toBeUndefined(); + expect(saved.adapterConfig.codexPermissionMode).toBeUndefined(); const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js"); - expect(() => resolvePaperclipRunnerProviderProfile(response.body.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" })); + expect(() => resolvePaperclipRunnerProviderProfile(saved.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" })); + }); + + it.each(["create", "hire", "convert"])("rejects a new Dot selection when only the general Runner rollout is enabled for %s", async mode => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false }); + const app = await createApp(); + const body = { name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } }; + const response = await requestApp(app, baseUrl => mode === "convert" + ? request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(body) + : request(baseUrl).post(`/api/companies/company-1/${mode === "hire" ? "agent-hires" : "agents"}`).send(body)); + expect(response.status, JSON.stringify(response.body)).toBe(422); + expect(response.body.details).toMatchObject({ code: "paperclip_runner_dot_disabled" }); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockAgentService.update).not.toHaveBeenCalled(); + }); + + it("requires the general Runner rollout when changing an existing Dot to Codex", async () => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); + mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } }); + const app = await createApp(); + const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111") + .send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true })); + expect(response.status, JSON.stringify(response.body)).toBe(422); + expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); + expect(mockAgentService.update).not.toHaveBeenCalled(); }); it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8af3e69782b..e612081bdfa 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2202,11 +2202,16 @@ export function agentRoutes( * (listEnabledServerAdapters documents the same rule: hidden from selection, * still functional for agents that already use them). */ - async function assertSelectableAdapterType(type: string | null | undefined): Promise { + async function assertSelectableAdapterType(type: string | null | undefined, config?: unknown): Promise { const adapterType = assertKnownAdapterType(type); if (adapterType === "paperclip_runner") { const experimental = await instanceSettings.getExperimental(); - if (experimental.enableNativeRunner !== true) { + if (asRecord(config)?.provider === "openai_dot") { + if (experimental.enableOpenAiDot !== true) throw unprocessable( + "OpenAI Dot is experimental and disabled on this instance.", + { code: "paperclip_runner_dot_disabled" }, + ); + } else if (experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -4515,10 +4520,13 @@ export function agentRoutes( ? rollbackConfig.adapterType : null, ); - if (rollbackAdapterType !== existing.adapterType) { - await assertSelectableAdapterType(rollbackAdapterType); - } const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {}; + const existingRollbackProvider = asRecord(existing.adapterConfig)?.provider; + if (rollbackAdapterType !== existing.adapterType || (rollbackAdapterType === "paperclip_runner" + && rollbackAdapterConfig.provider !== existingRollbackProvider + && (rollbackAdapterConfig.provider === "openai_dot" || existingRollbackProvider === "openai_dot"))) { + await assertSelectableAdapterType(rollbackAdapterType, rollbackAdapterConfig); + } assertNoAgentAdapterConfigMutation(req, rollbackAdapterConfig); assertNoAgentLocalAdapterHostCommandMutation(req, rollbackAdapterType, rollbackAdapterConfig); assertNoAgentProcessAdapterMutation( @@ -4675,7 +4683,7 @@ export function agentRoutes( hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig); hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null; } - hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType); + hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig); const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, hireInput.runtimeConfig); await assertFreshPaperclipRunnerProvider( @@ -4987,7 +4995,7 @@ export function agentRoutes( onboardingFirstAgent: createOnboardingFirstAgent, ...createInput } = req.body; - createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType); + createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig); const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, createInput.runtimeConfig); await assertFreshPaperclipRunnerProvider( @@ -5631,7 +5639,7 @@ export function agentRoutes( : existing.adapterType; const requestedAdapterType = nextAdapterType === existing.adapterType ? nextAdapterType - : await assertSelectableAdapterType(nextAdapterType); + : await assertSelectableAdapterType(nextAdapterType, patchData.adapterConfig); let requestedRuntimeConfig: Record | null = null; if (hasOwn(patchData, "runtimeConfig")) { const runtimeConfig = asRecord(patchData.runtimeConfig); @@ -5711,6 +5719,13 @@ export function agentRoutes( existing.adapterType === "paperclip_runner" ? existingAdapterConfig.provider : undefined; + // Moving between the standalone Dot choice and general Runner is a new + // selection. Preserve historical edits within the general Runner rollout. + if (!changingAdapterType && requestedAdapterType === "paperclip_runner" + && rawEffectiveAdapterConfig.provider !== existingRunnerProvider + && (rawEffectiveAdapterConfig.provider === "openai_dot" || existingRunnerProvider === "openai_dot")) { + await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig); + } if ( changingAdapterType || (requestedAdapterType === "paperclip_runner" && diff --git a/ui/src/components/RuntimeTestCard.tsx b/ui/src/components/RuntimeTestCard.tsx index 72b5fab543a..38a5e720aac 100644 --- a/ui/src/components/RuntimeTestCard.tsx +++ b/ui/src/components/RuntimeTestCard.tsx @@ -38,20 +38,30 @@ const copy = { }, } as const; +const prerequisiteCopy = { + idle: { title: "Check prerequisites", description: "Check instance settings and public connection configuration.", action: "Check setup" }, + running: { title: "Checking prerequisites", description: "Checking instance settings and public connection configuration…", action: "Checking…" }, + pass: { title: "Setup checks passed", description: "Complete pairing and verify event delivery before assigning work.", action: "Check again" }, + warn: { title: "Pairing required", description: "Create this agent, then pair it and verify event delivery.", action: "Check again" }, + fail: { title: "Prerequisite check failed", description: "Review the setup details and retry.", action: "Retry check" }, +} as const; + export function RuntimeTestCard({ state, result, error, onTest, disabled = false, + variant = "connection", }: { state: TestState | "warn"; result: AdapterEnvironmentTestResult | null; error?: string | null; onTest: () => void; disabled?: boolean; + variant?: "connection" | "prerequisites"; }) { - const content = copy[state]; + const content = (variant === "prerequisites" ? prerequisiteCopy : copy)[state]; const Icon = state === "running" ? Loader2 diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 66e35cf34bc..c9902d8cad1 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -1161,6 +1161,7 @@ function Setup({ )} { expect(container.textContent).toContain("OpenAI Dot"); expect(container.querySelector('[aria-label="Model"]')).toBeNull(); expect(container.textContent).not.toContain("Connect a model"); + expect(container.textContent).toContain("Check prerequisites"); + expect(container.textContent).not.toContain("runtime and model can respond"); await click("Finish setup"); expect(api.hire).not.toHaveBeenCalled(); expect(container.textContent).toContain("Acknowledge external provider billing");