diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 61382ea4cf..e9c795a339 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -200,8 +200,16 @@ export function AgentConfigFields({ () => getGlobalModelFallback(bakedEnv, effectiveProvider, config.env_vars), [bakedEnv, config.env_vars, effectiveProvider], ); + const modelField = fieldModel.fields.find( + (field) => field.kind === "model" && field.render === "control", + ); + // CLI-login harnesses apply this setting through ACP rather than an env var + // and provide their own default when no model override is persisted. + const modelIsOptional = modelField?.targetApplication.kind === "acpNative"; const modelIsValid = - (config.model?.trim().length ?? 0) > 0 || fallbackModel !== null; + modelIsOptional || + (config.model?.trim().length ?? 0) > 0 || + fallbackModel !== null; React.useEffect(() => { onValidityChange?.(modelIsValid); }, [modelIsValid, onValidityChange]); @@ -653,6 +661,7 @@ export function AgentConfigFields({ isCustomModelEditing={isCustomModelEditing} isRequired={ showRequiredIndicators && + !modelIsOptional && fallbackModel === null && !dependentFieldsDisabled } diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index f1056931bc..52cd01dd3a 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -20,6 +20,13 @@ import type { GlobalAgentConfig } from "@/shared/api/types"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; import { useAcpRuntimesQuery } from "@/features/agents/hooks"; +import { + formatRuntimeOptionLabel, + getDefaultPersonaRuntime, + resetConfigForHarnessChange, + sortPersonaRuntimes, +} from "@/features/agents/ui/agentConfigOptions"; +import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; import { AgentConfigFields, EMPTY_GLOBAL_CONFIG, @@ -105,17 +112,36 @@ export function AgentDefaultsEditor({ }); }, []); - // Resolve the buzz-agent runtime catalog entry for model discovery. const runtimesQuery = useAcpRuntimesQuery(); - const buzzAgentRuntime = React.useMemo( - () => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"), + const sortedRuntimes = React.useMemo( + () => sortPersonaRuntimes(runtimesQuery.data ?? []), [runtimesQuery.data], ); + // A missing/stale preference displays the same effective fallback the backend + // would use; it is persisted only after the user edits and saves this form. + // Keep persona ordering here so this shared editor matches agent dialogs. + const selectedRuntime = React.useMemo( + () => + sortedRuntimes.find( + (runtime) => runtime.id === config.preferred_runtime, + ) ?? + getDefaultPersonaRuntime(sortedRuntimes) ?? + sortedRuntimes[0], + [config.preferred_runtime, sortedRuntimes], + ); + const harnessOptions = React.useMemo( + () => + sortedRuntimes.map((runtime) => ({ + label: formatRuntimeOptionLabel(runtime), + value: runtime.id, + })), + [sortedRuntimes], + ); const configSurfaceLoading = isLoading || runtimesQuery.isLoading; const configSurfaceError = loadError || runtimesQuery.isError || - (!configSurfaceLoading && buzzAgentRuntime === undefined); + (!configSurfaceLoading && selectedRuntime === undefined); function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; @@ -125,6 +151,12 @@ export function AgentDefaultsEditor({ setSaveError(null); } + function handleHarnessChange(runtimeId: string) { + handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); + setIsCustomModelEditing(false); + setIsCustomProvider(false); + } + async function handleSave() { // Snapshot the config being submitted so we can detect edits that arrive // during the IPC round-trip and avoid clobbering the user's newer input. @@ -183,18 +215,36 @@ export function AgentDefaultsEditor({ Couldn't load agent defaults. Restart the app to try again. ) : ( - + <> +
+ + +
+ + )} {/* Save bar */} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index f1c05908e3..d0690cefc3 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -5,6 +5,7 @@ import { getDefaultPersonaRuntime, getPersonaModelOptions, getPersonaProviderOptions, + resetConfigForHarnessChange, runtimeSupportsLlmProviderSelection, } from "./agentConfigOptions.tsx"; import { formatModelDiscoveryErrorStatus } from "./personaModelDiscoveryStatus.ts"; @@ -145,6 +146,49 @@ test("runtimeSupportsLlmProviderSelection is false for codex and claude", () => assert.equal(runtimeSupportsLlmProviderSelection("claude"), false); }); +test("resetConfigForHarnessChange clears harness-specific values", () => { + const config = { + env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high", KEEP_ME: "yes" }, + model: "claude-opus", + preferred_runtime: "buzz-agent", + provider: "anthropic", + }; + + assert.deepEqual(resetConfigForHarnessChange(config, "claude"), { + env_vars: { KEEP_ME: "yes" }, + model: null, + preferred_runtime: "claude", + provider: null, + }); +}); + +test("resetConfigForHarnessChange preserves compatible provider selection", () => { + const config = { + env_vars: { KEEP_ME: "yes" }, + model: "old-model", + preferred_runtime: "claude", + provider: "anthropic", + }; + + assert.deepEqual(resetConfigForHarnessChange(config, "goose"), { + env_vars: { KEEP_ME: "yes" }, + model: null, + preferred_runtime: "goose", + provider: "anthropic", + }); +}); + +test("resetConfigForHarnessChange does not carry relay mesh to Goose", () => { + const config = { + env_vars: {}, + model: "auto", + preferred_runtime: "buzz-agent", + provider: "relay-mesh", + }; + + assert.equal(resetConfigForHarnessChange(config, "goose").provider, null); +}); + // ── getPersonaModelOptions — codex/claude do not use global provider ────────── // // The discovery call in AgentDefinitionDialog passes diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5272f559bc..6ae81ff6cb 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -1,4 +1,8 @@ -import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + GlobalAgentConfig, +} from "@/shared/api/types"; +import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig"; import type { RuntimeFileConfigSubset } from "@/shared/api/tauri"; // Dialogs import getDefaultPersonaRuntime via this re-export; lib code imports // directly from lib/resolvePersonaRuntime. @@ -173,6 +177,27 @@ export function runtimeSupportsLlmProviderSelection(runtimeId: string) { return runtimeId === "buzz-agent" || runtimeId === "goose"; } +/** Clears values whose meaning or support changes with the selected harness. */ +export function resetConfigForHarnessChange( + config: GlobalAgentConfig, + runtimeId: string, +): GlobalAgentConfig { + const nextEnvVars = { ...config.env_vars }; + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + + return { + ...config, + env_vars: nextEnvVars, + model: null, + preferred_runtime: runtimeId || null, + provider: + runtimeSupportsLlmProviderSelection(runtimeId) && + config.provider !== "relay-mesh" + ? config.provider + : null, + }; +} + function effectiveModelProviderForOptions( runtimeId: string, providerId: string | null | undefined, diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 23455f47c9..68a01f053d 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -5,8 +5,7 @@ import { AgentConfigFields, EMPTY_GLOBAL_CONFIG, } from "@/features/agents/ui/AgentConfigFields"; -import { BUZZ_AGENT_THINKING_EFFORT } from "@/features/agents/ui/buzzAgentConfig"; -import { runtimeSupportsLlmProviderSelection } from "@/features/agents/ui/agentConfigOptions"; +import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; import { createSaveCoalescer } from "./saveCoalescer"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; @@ -26,6 +25,7 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; +import { ONBOARDING_RUNTIME_ORDER } from "./onboardingRuntimeSelection"; import type { DefaultConfigStepActions } from "./types"; type DefaultConfigStepProps = { @@ -34,8 +34,6 @@ type DefaultConfigStepProps = { selectedRuntimeIds: readonly string[]; }; -const RUNTIME_ORDER = ["claude", "codex", "goose", "buzz-agent"]; - function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { if (!runtime) return "Select a harness"; return runtime.id === "buzz-agent" ? "Buzz" : runtime.label; @@ -49,11 +47,11 @@ function sortSelectedRuntimes( return runtimes .filter((runtime) => selectedRuntimeIdSet.has(runtime.id)) .sort((left, right) => { - const leftIndex = RUNTIME_ORDER.indexOf(left.id); - const rightIndex = RUNTIME_ORDER.indexOf(right.id); + const leftIndex = ONBOARDING_RUNTIME_ORDER.indexOf(left.id); + const rightIndex = ONBOARDING_RUNTIME_ORDER.indexOf(right.id); return ( - (leftIndex === -1 ? RUNTIME_ORDER.length : leftIndex) - - (rightIndex === -1 ? RUNTIME_ORDER.length : rightIndex) + (leftIndex === -1 ? ONBOARDING_RUNTIME_ORDER.length : leftIndex) - + (rightIndex === -1 ? ONBOARDING_RUNTIME_ORDER.length : rightIndex) ); }); } @@ -146,20 +144,7 @@ function AgentDefaultsSection({ const handleHarnessChange = React.useCallback( (runtimeId: string) => { - const nextEnvVars = { ...config.env_vars }; - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; - const nextProvider = - runtimeSupportsLlmProviderSelection(runtimeId) && - config.provider !== "relay-mesh" - ? config.provider - : null; - const next = { - ...config, - env_vars: nextEnvVars, - model: null, - preferred_runtime: runtimeId || null, - provider: nextProvider, - }; + const next = resetConfigForHarnessChange(config, runtimeId); setIsCustomModelEditing(false); setIsCustomProvider(false); setConfig(next); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 2d040ff348..dfd4e03045 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,8 +10,7 @@ import { importIdentity, persistCurrentIdentity, } from "@/shared/api/tauriIdentity"; -import { runtimeSupportsLlmProviderSelection } from "@/features/agents/ui/agentConfigOptions"; -import { BUZZ_AGENT_THINKING_EFFORT } from "@/features/agents/ui/buzzAgentConfig"; +import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions"; import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; @@ -94,20 +93,9 @@ export function MachineOnboardingFlow({ return; } - const nextEnvVars = { ...current.env_vars }; - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; - await setGlobalAgentConfig({ - ...current, - env_vars: nextEnvVars, - model: null, - preferred_runtime: preferredRuntimeId, - provider: - preferredRuntimeId && - runtimeSupportsLlmProviderSelection(preferredRuntimeId) && - current.provider !== "relay-mesh" - ? current.provider - : null, - }); + await setGlobalAgentConfig( + resetConfigForHarnessChange(current, preferredRuntimeId ?? ""), + ); }); runtimeSaveChain.current = save.then( () => undefined, diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index 2910d64905..046cca93a0 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -101,14 +101,29 @@ test("provider-backed selections drive the default model config step", () => { getDefaultModelConfigRuntimeId(["claude", "codex", "goose", "buzz-agent"]), "buzz-agent", ); +}); + +test("onboarding display order drives the preferred runtime", () => { assert.equal( - getPreferredRuntimeIdForSelection(["claude", "codex", "goose"]), - "goose", + getPreferredRuntimeIdForSelection([ + "buzz-agent", + "goose", + "codex", + "claude", + ]), + "claude", ); assert.equal( - getPreferredRuntimeIdForSelection(["claude", "codex"]), - "claude", + getPreferredRuntimeIdForSelection(["buzz-agent", "goose", "codex"]), + "codex", + ); + assert.equal( + getPreferredRuntimeIdForSelection(["buzz-agent", "goose"]), + "goose", ); + assert.equal(getPreferredRuntimeIdForSelection(["buzz-agent"]), "buzz-agent"); + assert.equal(getPreferredRuntimeIdForSelection(["custom"]), "custom"); + assert.equal(getPreferredRuntimeIdForSelection([]), null); }); test("any harness selection drives the defaults step", () => { diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 2b77e9a51b..3ff78e9b5f 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,11 +1,13 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -const KNOWN_ONBOARDING_RUNTIME_IDS = new Set([ - "buzz-agent", +export const ONBOARDING_RUNTIME_ORDER = [ "claude", "codex", "goose", -]); + "buzz-agent", +]; + +const KNOWN_ONBOARDING_RUNTIME_IDS = new Set(ONBOARDING_RUNTIME_ORDER); export function runtimeUsesDefaultModelConfig(runtimeId: string) { return runtimeId === "buzz-agent" || runtimeId === "goose"; @@ -22,7 +24,14 @@ export function getDefaultModelConfigRuntimeId(runtimeIds: readonly string[]) { export function getPreferredRuntimeIdForSelection( runtimeIds: readonly string[], ) { - return getDefaultModelConfigRuntimeId(runtimeIds) ?? runtimeIds[0] ?? null; + const selectedRuntimeIds = new Set(runtimeIds); + return ( + ONBOARDING_RUNTIME_ORDER.find((runtimeId) => + selectedRuntimeIds.has(runtimeId), + ) ?? + runtimeIds[0] ?? + null + ); } export function runtimeSelectionNeedsDefaultModelConfig( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 50985cc6c1..7282b74002 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6696,6 +6696,8 @@ function withMockRuntimeConfigMetadata( ): RawAcpRuntimeCatalogEntry { return { ...runtime, + node_required: runtime.node_required ?? false, + auth_status: runtime.auth_status ?? { status: "unknown" }, model_env_var: "model_env_var" in runtime ? runtime.model_env_var diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index da4214058e..59d6759e3d 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -198,6 +198,61 @@ test.describe("global agent config screenshots", () => { }); }); + test("settings renders and saves the persisted preferred harness", async ({ + page, + }) => { + await installMockBridge(page, { + globalAgentConfig: { + preferred_runtime: "claude", + provider: null, + model: null, + env_vars: {}, + }, + acpRuntimesCatalog: [...CATALOG_WITH_CLAUDE, CATALOG_WITH_CODEX[1]], + }); + + await openAiDefaultsSettings(page); + + const harness = page.getByTestId("global-agent-default-harness"); + await expect(harness).toHaveText("Claude Code"); + await expect(page.getByText("Provider", { exact: true })).toHaveCount(0); + await expect(page.locator("#global-agent-model")).toBeVisible(); + + // Make the form dirty, then return to Claude with no model override. The + // harness-native default keeps the now-actionable Save button enabled. + await harness.press("Enter"); + await page.getByTestId("global-agent-default-harness-option-codex").click(); + await harness.press("Enter"); + await page + .getByTestId("global-agent-default-harness-option-claude") + .click(); + await expect(page.getByTestId("global-agent-model")).toHaveText( + /Default model/, + ); + await expect( + page.getByRole("button", { name: "Save defaults" }), + ).toBeEnabled(); + + await harness.press("Enter"); + await page.getByTestId("global-agent-default-harness-option-codex").click(); + const model = page.getByTestId("global-agent-model"); + await model.click(); + await page.getByTestId("global-agent-model-option-gpt-5.5[high]").click(); + await page.getByRole("button", { name: "Save defaults" }).click(); + + const saved = await page.evaluate(async () => + ( + window as typeof window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), + ); + expect(saved).toMatchObject({ preferred_runtime: "codex" }); + }); + test("02-create-global-provider-shows-top-level-api-key", async ({ page, }) => { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index d56d8f83a7..4d1a293ecf 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -119,7 +119,7 @@ test("requires a runtime selection and routes Buzz Agent to config", async ({ expect(await readSavedRuntime(page)).toBe("buzz-agent"); }); -test("rapid harness toggles serialize the persisted preferred runtime", async ({ +test("rapid harness toggles serialize the prioritized preferred runtime", async ({ page, }) => { await installMockBridge( @@ -143,7 +143,7 @@ test("rapid harness toggles serialize the persisted preferred runtime", async ({ await page.waitForTimeout(300); await expect(next).toBeDisabled(); await expect(next).toBeEnabled({ timeout: 700 }); - expect(await readSavedRuntime(page)).toBe("buzz-agent"); + expect(await readSavedRuntime(page)).toBe("goose"); }); test("authenticated Claude saves the selected runtime and routes to defaults", async ({ @@ -557,7 +557,7 @@ test("changing setup-page harness clears an incompatible saved model", async ({ await expect.poll(() => readSavedRuntime(page)).toBe("claude"); }); -test("selecting all harnesses routes defaults through Buzz Agent", async ({ +test("selecting all harnesses prioritizes Claude for defaults", async ({ page, }) => { await installMockBridge( @@ -592,10 +592,10 @@ test("selecting all harnesses routes defaults through Buzz Agent", async ({ await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled(); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); - expect(await readSavedRuntime(page)).toBe("buzz-agent"); + expect(await readSavedRuntime(page)).toBe("claude"); const harnessSelect = page.getByTestId("global-agent-default-harness"); - await expect(harnessSelect).toHaveText("Buzz"); + await expect(harnessSelect).toHaveText("Claude"); await harnessSelect.click(); await expect( page.getByTestId("global-agent-default-harness-option-claude"), diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 0fd8338ce0..25a93820f8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -369,6 +369,7 @@ type MockBridgeOptions = { env_vars: Record; provider: string | null; model: string | null; + preferred_runtime?: string | null; }; bakedBuildEnv?: Array<{ key: string;