diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index c161a2e144..c3ae1ba586 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -297,6 +297,36 @@ export function AgentConfigFields({ const currentEffortForAutoClear = config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? ""; + + // When the selected harness changes outside this component (Back → setup + // page → choose a different harness → Next), the saved model can belong to + // the old harness. In onboarding, heal that stale value as soon as the new + // harness catalog proves it is unsupported; otherwise a Codex id like + // `gpt-5.5[low]` appears as a Claude Code custom model. + React.useEffect(() => { + if (!healOnMount) return; + const currentModel = (config.model ?? "").trim(); + if (currentModel.length === 0) return; + if (modelDiscoveryLoading || discoveredModelOptions === null) return; + if ( + discoveredModelOptions.some((option) => option.id.trim() === currentModel) + ) { + return; + } + + const nextEnvVars = { ...config.env_vars }; + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + onCustomModelEditingChange(false); + onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); + }, [ + config, + discoveredModelOptions, + modelDiscoveryLoading, + onConfigChange, + onCustomModelEditingChange, + healOnMount, + ]); + // Orphan-model clearing follows the mount-time healing policy above: the // backend resolves provider and model independently across layers // (agent → definition → global), so a saved global model WITHOUT a global @@ -452,6 +482,15 @@ export function AgentConfigFields({ const { validValues: effortValid, defaultValue: effortDefault } = getProviderEffortConfig(effortProvider, config.model ?? ""); const currentEffort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? ""; + // Claude Code and Codex both have harness-native effort semantics that are + // not represented by Buzz Agent's generic BUZZ_AGENT_THINKING_EFFORT env var: + // Claude exposes ACP config option `effort`; Codex currently encodes effort + // into model ids (e.g. `gpt-5.5[low]`). Hide the generic control until the + // config core can render harness-native options honestly. + const effortFieldVisible = + showEffortField && + selectedRuntimeId !== "claude" && + selectedRuntimeId !== "codex"; const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; @@ -617,7 +656,7 @@ export function AgentConfigFields({ {/* Thinking / Effort */} - {showEffortField ? ( + {effortFieldVisible ? (
{ + assert.equal( + resolveDefaultModelLabel({ + discoveredModelOptions: [ + { id: "", label: "Default model (claude-sonnet-5)" }, + { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, + ], + isSharedCompute: false, + }), + "Default model (claude-sonnet-5)", + ); +}); + +test("falls back to a generic harness default when discovery has no current model", () => { + assert.equal( + resolveDefaultModelLabel({ + discoveredModelOptions: [{ id: "", label: "Default model" }], + isSharedCompute: false, + }), + "Default model", + ); +}); + +test("an explicit inherited default label wins over harness discovery", () => { + assert.equal( + resolveDefaultModelLabel({ + defaultModelLabel: "Default model (team-model)", + discoveredModelOptions: [ + { id: "", label: "Default model (claude-sonnet-5)" }, + ], + isSharedCompute: false, + }), + "Default model (team-model)", + ); +}); diff --git a/desktop/src/features/agents/ui/agentConfigControls.tsx b/desktop/src/features/agents/ui/agentConfigControls.tsx index cea390ac8a..b6560a64f4 100644 --- a/desktop/src/features/agents/ui/agentConfigControls.tsx +++ b/desktop/src/features/agents/ui/agentConfigControls.tsx @@ -230,6 +230,24 @@ export function RequiredFieldLabel({ ); } +export function resolveDefaultModelLabel({ + defaultModelLabel, + discoveredModelOptions, + globalModel, + isSharedCompute, +}: { + defaultModelLabel?: string; + discoveredModelOptions: readonly PersonaModelOption[] | null; + globalModel?: string; + isSharedCompute: boolean; +}) { + return ( + defaultModelLabel ?? + discoveredModelOptions?.find((option) => option.id.trim() === "")?.label ?? + (isSharedCompute ? "Default (auto)" : getDefaultLlmModelLabel(globalModel)) + ); +} + export function AgentModelField({ disabled, discoveredModelOptions, @@ -305,16 +323,22 @@ export function AgentModelField({ }) { const trimmedModel = model.trim(); const isSharedCompute = provider?.trim() === "relay-mesh"; + const discoveredDefaultOption = discoveredModelOptions?.find( + (option) => option.id.trim() === "", + ); - // Buzz shared compute always has an automatic routing choice, even when - // discovery is empty or returns only explicit live model ids. + // Model discovery can report the harness's current/default model while the + // persisted value remains empty ("let the harness choose"). Treat that as a + // real, displayable option instead of a blank select state. Explicit labels + // from a lower-precedence/baked default still win when present. const defaultOption: PersonaModelOption = { id: "", - label: - defaultModelLabel ?? - (isSharedCompute - ? "Default (auto)" - : getDefaultLlmModelLabel(globalModel)), + label: resolveDefaultModelLabel({ + defaultModelLabel, + discoveredModelOptions, + globalModel, + isSharedCompute, + }), }; const discoveredWithoutDefault = (discoveredModelOptions ?? []).filter( (option) => option.id.trim() !== "", @@ -322,7 +346,9 @@ export function AgentModelField({ const baseModelOptions = isSharedCompute ? [defaultOption, ...discoveredWithoutDefault] : [ - ...(allowDefaultModel ? [defaultOption] : []), + ...(allowDefaultModel || discoveredDefaultOption + ? [defaultOption] + : []), ...discoveredWithoutDefault, ]; const shouldShowPendingModelOption = @@ -402,6 +428,16 @@ export function AgentModelField({ trimmedModel.length > 0 ? trimmedModel : undefined; + // While discovery is in flight with nothing selected, the closed field + // reads "Loading models…" instead of a select-prompt — the field isn't + // waiting on the user, it's waiting on the harness. + const restingPlaceholder = + modelDiscoveryLoading && + discoveredModelOptions === null && + trimmedModel.length === 0 && + !isCustomModelEditing + ? "Loading models..." + : placeholder; const modelSelect = useCustomSelect ? ( { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { id: "default", name: null, description: null }, + { id: "claude-opus-4-8", name: null, description: null }, + { id: "claude-sonnet-5", name: null, description: null }, + ], + }), + "", + ); + + // Exactly one default row (id ""), and no raw "default" entry remains. + assert.deepEqual( + options.map((option) => option.id), + ["", "claude-opus-4-8", "claude-sonnet-5"], + ); + assert.equal(options[0].label, "Default model"); +}); + +test("default row shows the harness-reported current model when available", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + agentDefaultModel: "gpt-5.5[high]", + models: [ + { id: "gpt-5.5", name: "GPT-5.5", description: null }, + { id: "gpt-5.4", name: "GPT-5.4", description: null }, + ], + }), + "", + ); + + assert.equal(options[0].id, ""); + assert.equal(options[0].label, "Default model (gpt-5.5[high])"); + assert.deepEqual( + options.slice(1).map((option) => option.id), + ["gpt-5.5", "gpt-5.4"], + ); +}); + +test("the 'default' id match is case-insensitive and trimmed", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { id: " Default ", name: null, description: null }, + { id: "claude-sonnet-5", name: null, description: null }, + ], + }), + "", + ); + + assert.deepEqual( + options.map((option) => option.id), + ["", "claude-sonnet-5"], + ); +}); + +test("explicit-model providers get no default row (no harness default entry)", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { id: "goose-claude-4-6-sonnet", name: null, description: null }, + ], + }), + "anthropic", + ); + + assert.deepEqual( + options.map((option) => option.id), + ["goose-claude-4-6-sonnet"], + ); +}); + +test("relay-mesh keeps its automatic routing default row", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [{ id: "llama-3", name: "Llama 3", description: null }], + }), + "relay-mesh", + ); + + assert.equal(options[0].id, ""); + assert.equal(options[0].label, "Default (auto)"); +}); + +test("returns null when discovery is unsupported or empty", () => { + assert.equal( + getDiscoveredPersonaModelOptions( + response({ supportsSwitching: false }), + "", + ), + null, + ); + assert.equal(getDiscoveredPersonaModelOptions(null, ""), null); +}); diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts index d1525003da..c8f0b0f3bf 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts @@ -25,7 +25,18 @@ function stableModelDiscoveryEnvKey(envVars: EnvVarsValue): string { ); } -function getDiscoveredPersonaModelOptions( +/** + * True when a harness catalog entry is the harness's own "use my default" + * row (e.g. Claude Code ships a literal `default` model id). Such entries + * mean the same thing as leaving the model unset, so the UI merges them + * into the single canonical default row instead of showing two rows for + * one idea. + */ +function isHarnessDefaultModelEntry(model: { id: string }) { + return model.id.trim().toLowerCase() === "default"; +} + +export function getDiscoveredPersonaModelOptions( response: AgentModelsResponse | null, provider: string, ): readonly PersonaModelOption[] | null { @@ -33,23 +44,38 @@ function getDiscoveredPersonaModelOptions( return null; } - const defaultModelOption = providerRequiresExplicitModel(provider) - ? [] - : [ - { - id: "", - label: - provider === "relay-mesh" - ? "Default (auto)" - : response.agentDefaultModel?.trim() - ? `Default model (${response.agentDefaultModel})` - : "Default model", - }, - ]; + // One row per idea: the harness's own default catalog entry (if any) is + // absorbed into the canonical default row. Selecting it keeps the stored + // model unset — behaviorally identical, and it avoids two saved states + // ("default" vs unset) that mean the same thing. + const explicitModels = response.models.filter( + (model) => !isHarnessDefaultModelEntry(model), + ); + const harnessDefaultEntry = response.models.find(isHarnessDefaultModelEntry); + const agentDefaultModel = response.agentDefaultModel?.trim(); + + const defaultModelOption = + providerRequiresExplicitModel(provider) && harnessDefaultEntry === undefined + ? [] + : [ + { + id: "", + label: + provider === "relay-mesh" + ? "Default (auto)" + : agentDefaultModel + ? `Default model (${agentDefaultModel})` + : "Default model", + }, + ]; + + if (explicitModels.length === 0 && defaultModelOption.length === 0) { + return null; + } return [ ...defaultModelOption, - ...response.models.map((model) => ({ + ...explicitModels.map((model) => ({ id: model.id, label: model.name?.trim() || model.id, })), diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 22784b1065..bf1dd05d97 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -140,26 +140,45 @@ function AgentDefaultsSection({ [selectedRuntimes], ); - function handleHarnessChange(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, - }; - setIsCustomModelEditing(false); - setIsCustomProvider(false); - setConfig(next); - coalescerRef.current?.enqueue(next); - } + 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, + }; + setIsCustomModelEditing(false); + setIsCustomProvider(false); + setConfig(next); + coalescerRef.current?.enqueue(next); + }, + [config], + ); + + React.useEffect(() => { + if (isLoading || !selectedRuntimeId) return; + if (config.preferred_runtime === selectedRuntimeId) return; + + // The user can go Back, change which harnesses are selected, then return to + // this page without using this page's own harness dropdown. Reconcile that + // effective harness change through the same reset path so a Codex model + // never survives into Claude Code as a custom model (or vice versa). + handleHarnessChange(selectedRuntimeId); + }, [ + config.preferred_runtime, + handleHarnessChange, + isLoading, + selectedRuntimeId, + ]); return (
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index f7ffd70fc3..2d040ff348 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,8 @@ 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 { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; @@ -82,9 +84,29 @@ export function MachineOnboardingFlow({ const save = runtimeSaveChain.current.then(async () => { const current = await getGlobalAgentConfig(); + const selectedHarnessChanged = + current.preferred_runtime !== preferredRuntimeId; + if (!selectedHarnessChanged) { + await setGlobalAgentConfig({ + ...current, + preferred_runtime: preferredRuntimeId, + }); + 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, }); }); runtimeSaveChain.current = save.then( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c3dcf4db12..6386dfe4e5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9775,8 +9775,14 @@ export function maybeInstallE2eTauriMocks() { }, ]; const codexRuntimeModels = [ - { id: "codex-mini", name: "Codex mini", description: null }, - { id: "codex-pro", name: "Codex pro", description: null }, + { id: "gpt-5.5", name: "GPT-5.5", description: null }, + { id: "gpt-5.5[low]", name: "GPT-5.5 (low)", description: null }, + { + id: "gpt-5.5[medium]", + name: "GPT-5.5 (medium)", + description: null, + }, + { id: "gpt-5.5[high]", name: "GPT-5.5 (high)", description: null }, ]; if (provider === "relay-mesh") { if (!mockMeshState.admitted) { @@ -9808,7 +9814,9 @@ export function maybeInstallE2eTauriMocks() { agentName: "mock-agent", agentVersion: "0.0.0", models, - agentDefaultModel: null, + agentDefaultModel: agentCommand.includes("codex") + ? "gpt-5.5[high]" + : null, selectedModel: null, supportsSwitching: true, }; diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 3c2acae096..d56d8f83a7 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -68,17 +68,24 @@ async function chooseConfigDropdownOption( await page.getByTestId(`${triggerTestId}-option-${value || "empty"}`).click(); } -async function readSavedRuntime(page: Parameters[0]) { - const savedConfig = await page.evaluate(() => +async function readSavedConfig(page: Parameters[0]) { + return await page.evaluate(() => ( window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload: unknown, - ) => Promise<{ preferred_runtime?: string | null }>; + ) => Promise<{ + model?: string | null; + preferred_runtime?: string | null; + }>; } ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), ); +} + +async function readSavedRuntime(page: Parameters[0]) { + const savedConfig = await readSavedConfig(page); return savedConfig?.preferred_runtime ?? null; } @@ -159,6 +166,12 @@ test("authenticated Claude saves the selected runtime and routes to defaults", a await expect(page.getByTestId("global-agent-default-harness")).toHaveText( "Claude", ); + // Claude Code effort is real, but it is exposed as a Claude ACP-native + // config option, not Buzz Agent's generic effort env var. Hide the generic + // control until the config core can render that native option. + await expect( + page.getByTestId("global-agent-thinking-effort-select"), + ).toHaveCount(0); expect(await readSavedRuntime(page)).toBe("claude"); }); @@ -484,17 +497,66 @@ test("multiple CLI harnesses route to default harness selection", async ({ await expect.poll(() => readSavedRuntime(page)).toBe("codex"); await expect(page.getByTestId("global-agent-provider")).toHaveCount(0); const modelSelect = page.getByTestId("global-agent-model"); - const effortSelect = page.getByTestId("global-agent-thinking-effort-select"); await expect(modelSelect).toBeVisible(); - await expect(effortSelect).toBeVisible(); - await expect(modelSelect).toHaveText("Select a model"); + // Codex model ids can encode effort (for example, `gpt-5.5[low]`). Until the + // config core models Codex-native options directly, don't show Buzz Agent's + // generic effort field beside Codex's model catalog. + await expect( + page.getByTestId("global-agent-thinking-effort-select"), + ).toHaveCount(0); + await expect(modelSelect).toHaveText("Default model (gpt-5.5[high])"); await modelSelect.click(); await expect( - page.getByTestId("global-agent-model-option-codex-mini"), + page.getByTestId("global-agent-model-option-gpt-5.5[low]"), ).toBeVisible(); await page.keyboard.press("Escape"); }); +test("changing setup-page harness clears an incompatible saved model", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + availableRuntime("claude", { status: "logged_in" }), + availableRuntime("codex", { status: "logged_in" }), + ], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + await page.getByTestId("onboarding-runtime-codex").click(); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + await chooseConfigDropdownOption(page, "global-agent-model", "gpt-5.5[low]"); + await expect(page.getByTestId("global-agent-model")).toHaveText( + "gpt-5.5[low]", + ); + + await page.getByTestId("onboarding-back").click(); + await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); + await page.getByTestId("onboarding-runtime-codex").click(); + await page.getByTestId("onboarding-runtime-claude").click(); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude", + ); + await expect(page.getByTestId("global-agent-model")).not.toHaveText( + "Custom model...", + ); + await expect(page.getByLabel("Custom model ID")).toHaveCount(0); + await expect + .poll(async () => (await readSavedConfig(page))?.model) + .toBeNull(); + await expect.poll(() => readSavedRuntime(page)).toBe("claude"); +}); + test("selecting all harnesses routes defaults through Buzz Agent", async ({ page, }) => { @@ -557,7 +619,7 @@ test("selecting all harnesses routes defaults through Buzz Agent", async ({ await expect(page.getByTestId("global-agent-model")).toBeVisible(); await expect( page.getByTestId("global-agent-thinking-effort-select"), - ).toBeVisible(); + ).toHaveCount(0); await chooseConfigDropdownOption( page,