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,