Fix harness default model states in onboarding (#2156)

This commit is contained in:
morgmart
2026-07-19 21:49:19 -07:00
committed by GitHub
parent e43b2d5aac
commit a6432ab133
9 changed files with 420 additions and 56 deletions
@@ -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({
</div>
{/* Thinking / Effort */}
{showEffortField ? (
{effortFieldVisible ? (
<div className={blockClassName}>
<EffortSelectField
currentEffort={dependentFieldsDisabled ? "" : currentEffort}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDefaultModelLabel } from "./agentConfigControls.tsx";
test("uses the harness-discovered default model label for an unset model", () => {
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)",
);
});
@@ -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 ? (
<AgentDropdownSelect
@@ -411,7 +447,7 @@ export function AgentModelField({
id={id}
onValueChange={handleModelSelectChange}
options={modelOptions}
placeholder={placeholder}
placeholder={restingPlaceholder}
placeholderClassName={placeholderClassName}
searchable
selectedLabel={stableSelectedModelLabel}
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getDiscoveredPersonaModelOptions } from "./usePersonaModelDiscovery.ts";
function response(overrides = {}) {
return {
agentName: "mock",
agentVersion: "0.0.0",
models: [],
agentDefaultModel: null,
selectedModel: null,
supportsSwitching: true,
...overrides,
};
}
test("merges the harness's own 'default' catalog entry into the canonical default row", () => {
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);
});
@@ -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,
})),
@@ -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 (
<section className="w-full space-y-4 text-left text-sm">
@@ -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(
+11 -3
View File
@@ -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,
};
@@ -68,17 +68,24 @@ async function chooseConfigDropdownOption(
await page.getByTestId(`${triggerTestId}-option-${value || "empty"}`).click();
}
async function readSavedRuntime(page: Parameters<typeof installMockBridge>[0]) {
const savedConfig = await page.evaluate(() =>
async function readSavedConfig(page: Parameters<typeof installMockBridge>[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<typeof installMockBridge>[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,