diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 19667c60c4d..dd14e76f297 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -173,7 +173,7 @@ export function CodexLocalConfigFields({ {!hideEngineChoice && ( + {allowExecutionEngineSelection && engine !== "cli" && ( +

This engine is unsupported on Boat. Choose Codex CLI before saving or testing.

+ )}
)} {runnerManaged && runnerProvider !== "openai_dot" && ( diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 533971b75d3..5ba555ded8a 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -111,7 +111,7 @@ vi.mock("../adapters", () => ({ // adapter, so a test can assert the plumbing without rendering a real // adapter's fields. ConfigFields: (props: AdapterConfigFieldsProps) => { - if (type === "paperclip_runner") return ; + if (type === "paperclip_runner" || (type === "codex_local" && props.allowExecutionEngineSelection)) return ; const { adapterType, hideInstructionsFile, managedSandboxOnly, allowExecutionEngineSelection } = props; return adapterType === "hermes_gateway" ?
Hermes Gateway fields
@@ -3878,6 +3878,41 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { expect(result.container.textContent).not.toContain("/srv/agents/cody"); }); + it.each(["acp", undefined])("requires explicit CLI repair before saving an unsupported Boat Codex engine (%s)", async engine => { + setManagedSandboxOnly(true); + const saveActions = vi.fn(); + const result = await renderForm( + [makeEnvironment({ id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } })], + { adapterType: "codex_local", defaultEnvironmentId: "boat-1", adapterConfig: { engine } }, + { onSaveActionChange: saveActions, showAdapterTestEnvironmentButton: true }, + ); + roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); + expect(result.container.textContent).toContain("Choose Codex CLI before saving or testing"); + expect(saveActions.mock.lastCall?.[0]).toBeNull(); + expect(findButton(result.container, "Test")?.disabled).toBe(true); + expect(result.onSave).not.toHaveBeenCalled(); + const engineSelect = Array.from(result.container.querySelectorAll("select")).find(select => + Array.from(select.options).some(option => option.textContent === "Codex CLI"), + )!; + expect(engineSelect.value).toBe(engine ?? "auto"); + expect(engineSelect.selectedOptions[0].disabled).toBe(true); + expect(Array.from(engineSelect.options).filter(option => !option.disabled).map(option => option.value)).toEqual(["cli"]); + await act(async () => { + engineSelect.value = "cli"; + engineSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(result.container.textContent).not.toContain("Choose Codex CLI before saving or testing"); + expect(saveActions.mock.lastCall?.[0]).toEqual(expect.any(Function)); + await act(async () => saveActions.mock.lastCall![0]()); + expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ engine: "cli" }) })); + }); + it.each(["active", "archived"] as const)("only exposes the engine for a resolved active Boat, keeping managed host paths hidden (%s)", async status => { setManagedSandboxOnly(true); const result = await renderForm( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index ec0231c423d..144cded17c2 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -564,39 +564,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setEnvironmentEditorKey(key => key + 1); }, []); - const handleSave = useCallback(async () => { - if (isCreate) return; - const flushedEnv = flushEnvironmentDraft(); - const nextOverlay = flushedEnv - ? { - ...overlay, - adapterConfig: { - ...overlay.adapterConfig, - env: flushedEnv, - }, - } - : overlay; - if (!isOverlayDirty(nextOverlay)) return; - await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay)); - }, [isCreate, isDirty, overlay, props]); - - useEffect(() => { - if (!isCreate) { - props.onDirtyChange?.(isDirty); - props.onSaveActionChange?.(handleSave); - props.onCancelActionChange?.(handleCancel); - } - }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); - - useEffect(() => { - if (isCreate) return; - return () => { - props.onSaveActionChange?.(null); - props.onCancelActionChange?.(null); - props.onDirtyChange?.(false); - }; - }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); - // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; @@ -839,6 +806,45 @@ export function AgentConfigForm(props: AgentConfigFormProps) { () => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null, [environments, effectiveLoginEnvironmentId], ); + const attachedBoat = experimentalSettings !== undefined + && effectiveLoginEnvironment?.status === "active" + && effectiveLoginEnvironment.driver === "computer" + && effectiveLoginEnvironment.config.provider === "boat"; + const unsupportedBoatCodexEngine = attachedBoat && adapterType === "codex_local" + && (isCreate ? props.values.codexEngine : eff("adapterConfig", "engine", config.engine)) !== "cli"; + const handleSave = useCallback(async () => { + if (isCreate || unsupportedBoatCodexEngine) return; + const flushedEnv = flushEnvironmentDraft(); + const nextOverlay = flushedEnv + ? { + ...overlay, + adapterConfig: { + ...overlay.adapterConfig, + env: flushedEnv, + }, + } + : overlay; + if (!isOverlayDirty(nextOverlay)) return; + await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay)); + }, [isCreate, isDirty, overlay, props, unsupportedBoatCodexEngine]); + + useEffect(() => { + if (!isCreate) { + props.onDirtyChange?.(isDirty); + props.onSaveActionChange?.(unsupportedBoatCodexEngine ? null : handleSave); + props.onCancelActionChange?.(handleCancel); + } + }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel, unsupportedBoatCodexEngine]); + + useEffect(() => { + if (isCreate) return; + return () => { + props.onSaveActionChange?.(null); + props.onCancelActionChange?.(null); + props.onDirtyChange?.(false); + }; + }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); + // Load the sandbox provider capabilities. A login that runs on a real // pseudo-terminal needs a provider that advertises the login pseudo-terminal // capability. The login panel gate reads this to hide the panel for a provider @@ -969,10 +975,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // every adapter without a per-adapter edit. hideInstructionsFile: hideInstructionsFile || hideHostPaths || isDotRunner, managedSandboxOnly: hideHostPaths || isDotRunner, - allowExecutionEngineSelection: experimentalSettings !== undefined - && effectiveLoginEnvironment?.status === "active" - && effectiveLoginEnvironment.driver === "computer" - && effectiveLoginEnvironment.config.provider === "boat", + allowExecutionEngineSelection: attachedBoat, openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true, }; @@ -1101,7 +1104,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const [testActionError, setTestActionError] = useState(null); const testActionLabel = "Test"; const isSavePending = !isCreate && Boolean(props.isSaving); - const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId; + const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId || unsupportedBoatCodexEngine; // Drop a stale Test result when the adapter type or the effective environment // changes. A held result would keep the login affordance visible for a target @@ -1406,7 +1409,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Unsaved changes -
@@ -1458,7 +1461,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 315b0c26e73..0e623bc77b1 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -146,7 +146,8 @@ function Setup({ const [effort, setEffort] = useState(""); const [modelOpen, setModelOpen] = useState(false); const [environmentOverride, setEnvironmentOverride] = useState(""); - const [boatEngine, setBoatEngine] = useState<"cli" | "acp" | undefined>(); + const [boatEngines, setBoatEngines] = useState>>({}); + const boatEngine = boatEngines[adapterType]; const [provider, setProvider] = useState("openrouter"); const [apiKey, setApiKey] = useState(""); const [providerBinding, setProviderBinding] = useState( @@ -352,12 +353,12 @@ function Setup({ value={boatEngine ?? "cli"} disabled={busy} onChange={(event) => { - setBoatEngine(event.target.value === "acp" ? "acp" : "cli"); + setBoatEngines(current => ({ ...current, [adapterType]: adapterType !== "codex_local" && event.target.value === "acp" ? "acp" : "cli" })); resetTest(); }} > - + {adapterType !== "codex_local" && } ) : null; diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 40aa0d9ec45..a5e729d6014 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -312,7 +312,7 @@ describe("New agent setup", () => { }); it.each([ ["codex_local", "OpenAI", undefined], ["claude_local", "Claude", undefined], - ["codex_local", "OpenAI", "acp"], ["claude_local", "Claude", "acp"], + ["claude_local", "Claude", "acp"], ])("shows Boat's CLI default before connecting and preserves an explicit engine for %s (%s, %s)", async (adapter, provider, choice) => { envApi.list.mockResolvedValue([ { id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } }, @@ -323,6 +323,7 @@ describe("New agent setup", () => { const engine = container.querySelector('[aria-label="Execution engine"]')!; expect(engine).not.toBeNull(); expect(engine.value).toBe("cli"); + expect(Array.from(engine.options).some(option => option.value === "acp")).toBe(adapter === "claude_local"); expect(engine.selectedOptions[0].textContent).toBe(adapter === "codex_local" ? "Codex CLI" : "Claude CLI"); expect(api.testEnvironment).not.toHaveBeenCalled(); if (choice) await act(async () => {