Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion desktop/src/features/agents/ui/AgentConfigFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -617,7 +656,7 @@ export function AgentConfigFields({
</div>

{/* Thinking / Effort */}
{showEffortField ? (
{effortFieldVisible ? (
<div className={blockClassName}>
<EffortSelectField
currentEffort={dependentFieldsDisabled ? "" : currentEffort}
Expand Down
40 changes: 40 additions & 0 deletions desktop/src/features/agents/ui/agentConfigControls.test.mjs
Original file line number Diff line number Diff line change
@@ -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)",
);
});
54 changes: 45 additions & 9 deletions desktop/src/features/agents/ui/agentConfigControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -305,24 +323,32 @@ 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() !== "",
);
const baseModelOptions = isSharedCompute
? [defaultOption, ...discoveredWithoutDefault]
: [
...(allowDefaultModel ? [defaultOption] : []),
...(allowDefaultModel || discoveredDefaultOption
? [defaultOption]
: []),
...discoveredWithoutDefault,
];
const shouldShowPendingModelOption =
Expand Down Expand Up @@ -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
Expand All @@ -411,7 +447,7 @@ export function AgentModelField({
id={id}
onValueChange={handleModelSelectChange}
options={modelOptions}
placeholder={placeholder}
placeholder={restingPlaceholder}
placeholderClassName={placeholderClassName}
searchable
selectedLabel={stableSelectedModelLabel}
Expand Down
112 changes: 112 additions & 0 deletions desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
56 changes: 41 additions & 15 deletions desktop/src/features/agents/ui/usePersonaModelDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,57 @@ 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 {
if (!response?.supportsSwitching || response.models.length === 0) {
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,
})),
Expand Down
Loading
Loading