diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index b57ffaa685..50206567ad 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,85 +9,9 @@ use crate::managed_agents::{ AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, }; -pub(crate) struct KnownAcpRuntime { - pub id: &'static str, - pub label: &'static str, - pub commands: &'static [&'static str], - pub aliases: &'static [&'static str], - pub avatar_url: &'static str, - /// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI. - /// directly. Will be removed when runtime discovery is simplified. - pub mcp_command: Option<&'static str>, - /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent. - pub mcp_hooks: bool, - /// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing). - pub underlying_cli: Option<&'static str>, - /// Shell commands to install the runtime CLI itself (run sequentially). - pub cli_install_commands: &'static [&'static str], - /// Windows-specific CLI install commands (e.g. PowerShell installers). - /// When non-empty on Windows, these are used instead of `cli_install_commands`. - #[allow(dead_code)] // read only on Windows via cli_install_commands_for_os() - pub cli_install_commands_windows: &'static [&'static str], - /// Shell commands to install the ACP adapter (run sequentially, after CLI). - pub adapter_install_commands: &'static [&'static str], - /// Link to docs/repo for manual instructions. - pub install_instructions_url: &'static str, - /// Human-readable hint about installing the CLI binary. - pub cli_install_hint: &'static str, - /// Human-readable hint about installing the ACP adapter. - pub adapter_install_hint: &'static str, - /// Harness-specific skill discovery directory (e.g. `.goose/skills`). - /// `Some(dir)` → Buzz creates a symlink at `//buzz-cli` - /// pointing to the canonical `.agents/skills/buzz-cli`. `None` → this - /// runtime reads the canonical path directly or has no skill support. - pub skill_dir: Option<&'static str>, - /// Whether this runtime handles model switching via ACP protocol natively. - /// Currently unused — env var injection runs unconditionally regardless of - /// this value. Retained as scaffolding for when ACP model switching matures. - #[allow(dead_code)] - pub supports_acp_model_switching: bool, - pub model_env_var: Option<&'static str>, - pub provider_env_var: Option<&'static str>, - pub provider_locked: bool, - pub default_env: &'static [(&'static str, &'static str)], - pub config_file_path: Option<&'static str>, - #[allow(dead_code)] // reserved for format-based dispatch when readers are unified - pub config_file_format: Option<&'static str>, - pub supports_acp_native_config: bool, // tier 1a: config/read+write - pub thinking_env_var: Option<&'static str>, - /// Env var for normalizing `max_output_tokens`. `None` when the harness - /// does not have a first-class env var for this field (config-file only). - pub max_tokens_env_var: Option<&'static str>, - /// Env var for normalizing `context_limit`. `None` when not applicable. - pub context_limit_env_var: Option<&'static str>, - /// Normalized field keys that must be set for this harness to function. - /// Used by the config bridge to mark fields as required in the UI. - /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). - pub required_normalized_fields: &'static [&'static str], - /// Human-readable hint shown in Doctor when the runtime is available but not - /// authenticated. `None` for runtimes that have no login step (goose, buzz-agent). - pub login_hint: Option<&'static str>, - /// CLI args for probing authentication status. `args[0]` is the binary name; - /// the remainder are the subcommand. `None` for runtimes with no login step. - pub auth_probe_args: Option<&'static [&'static str]>, -} +mod runtime_metadata; -impl KnownAcpRuntime { - /// Return the CLI install commands for the current platform. - /// - /// On Windows, returns `cli_install_commands_windows` when non-empty, - /// falling back to the default `cli_install_commands`. On other platforms - /// always returns `cli_install_commands`. - pub fn cli_install_commands_for_os(&self) -> &[&str] { - #[cfg(windows)] - { - if !self.cli_install_commands_windows.is_empty() { - return self.cli_install_commands_windows; - } - } - self.cli_install_commands - } -} +pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -1318,6 +1242,9 @@ pub fn discover_acp_runtimes() -> Vec { binary_path, default_args, mcp_command: runtime.mcp_command.map(str::to_string), + model_env_var: runtime.model_env_var.map(str::to_string), + provider_env_var: runtime.provider_env_var.map(str::to_string), + thinking_env_var: runtime.thinking_env_var.map(str::to_string), install_hint, install_instructions_url: runtime.install_instructions_url.to_string(), can_auto_install, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs new file mode 100644 index 0000000000..4dfcad4256 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -0,0 +1,80 @@ +/// Static capabilities and installation metadata for a known ACP runtime. +pub(crate) struct KnownAcpRuntime { + pub id: &'static str, + pub label: &'static str, + pub commands: &'static [&'static str], + pub aliases: &'static [&'static str], + pub avatar_url: &'static str, + /// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI + /// directly. Will be removed when runtime discovery is simplified. + pub mcp_command: Option<&'static str>, + /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent. + pub mcp_hooks: bool, + /// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing). + pub underlying_cli: Option<&'static str>, + /// Shell commands to install the runtime CLI itself (run sequentially). + pub cli_install_commands: &'static [&'static str], + /// Windows-specific CLI install commands (e.g. PowerShell installers). + /// When non-empty on Windows, these are used instead of `cli_install_commands`. + #[allow(dead_code)] // read only on Windows via cli_install_commands_for_os() + pub cli_install_commands_windows: &'static [&'static str], + /// Shell commands to install the ACP adapter (run sequentially, after CLI). + pub adapter_install_commands: &'static [&'static str], + /// Link to docs/repo for manual instructions. + pub install_instructions_url: &'static str, + /// Human-readable hint about installing the CLI binary. + pub cli_install_hint: &'static str, + /// Human-readable hint about installing the ACP adapter. + pub adapter_install_hint: &'static str, + /// Harness-specific skill discovery directory (e.g. `.goose/skills`). + /// `Some(dir)` → Buzz creates a symlink at `//buzz-cli` + /// pointing to the canonical `.agents/skills/buzz-cli`. `None` → this + /// runtime reads the canonical path directly or has no skill support. + pub skill_dir: Option<&'static str>, + /// Whether this runtime handles model switching via ACP protocol natively. + /// Currently unused — env var injection runs unconditionally regardless of + /// this value. Retained as scaffolding for when ACP model switching matures. + #[allow(dead_code)] + pub supports_acp_model_switching: bool, + pub model_env_var: Option<&'static str>, + pub provider_env_var: Option<&'static str>, + pub provider_locked: bool, + pub default_env: &'static [(&'static str, &'static str)], + pub config_file_path: Option<&'static str>, + #[allow(dead_code)] // reserved for format-based dispatch when readers are unified + pub config_file_format: Option<&'static str>, + pub supports_acp_native_config: bool, // tier 1a: config/read+write + pub thinking_env_var: Option<&'static str>, + /// Env var for normalizing `max_output_tokens`. `None` when the harness + /// does not have a first-class env var for this field (config-file only). + pub max_tokens_env_var: Option<&'static str>, + /// Env var for normalizing `context_limit`. `None` when not applicable. + pub context_limit_env_var: Option<&'static str>, + /// Normalized field keys that must be set for this harness to function. + /// Used by the config bridge to mark fields as required in the UI. + /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). + pub required_normalized_fields: &'static [&'static str], + /// Human-readable hint shown in Doctor when the runtime is available but not + /// authenticated. `None` for runtimes that have no login step (goose, buzz-agent). + pub login_hint: Option<&'static str>, + /// CLI args for probing authentication status. `args[0]` is the binary name; + /// the remainder are the subcommand. `None` for runtimes with no login step. + pub auth_probe_args: Option<&'static [&'static str]>, +} + +impl KnownAcpRuntime { + /// Return the CLI install commands for the current platform. + /// + /// On Windows, returns `cli_install_commands_windows` when non-empty, + /// falling back to the default `cli_install_commands`. On other platforms + /// always returns `cli_install_commands`. + pub fn cli_install_commands_for_os(&self) -> &[&str] { + #[cfg(windows)] + { + if !self.cli_install_commands_windows.is_empty() { + return self.cli_install_commands_windows; + } + } + self.cli_install_commands + } +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7f69b77e0d..f2d540a324 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -573,6 +573,12 @@ pub struct AcpRuntimeCatalogEntry { pub binary_path: Option, pub default_args: Vec, pub mcp_command: Option, + /// Environment variable used to apply the initial model, when supported. + pub model_env_var: Option, + /// Environment variable used to apply the selected LLM provider, when supported. + pub provider_env_var: Option, + /// Environment variable used to apply thinking effort, when supported. + pub thinking_env_var: Option, pub install_hint: String, pub install_instructions_url: String, /// true when at least one automated install step is available diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md new file mode 100644 index 0000000000..180253440b --- /dev/null +++ b/desktop/src/features/agents/AGENTS.md @@ -0,0 +1,77 @@ +# Agent Configuration — Contributor Rules + +Scope: `desktop/src/features/agents/` (config surfaces, shared config renderer, +and the agent config core). Read this before changing how harness / provider / +model / effort configuration is modeled, rendered, persisted, or applied. + +Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault +(PR sequence, decisions log). PRs: #2140 (rename), #2148 (flag reduction), +#2156 (honest model states), #2158 (Agent Config Core). + +## The one rule + +**Harness capability facts have exactly one source: the Rust runtime catalog.** +`KnownAcpRuntime` (`desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs`) +declares each harness's model/provider/effort env keys and capabilities. Spawn +applies them; `AcpRuntimeCatalogEntry` exposes them over IPC; and +`lib/agentConfigCore.ts` projects them into field descriptors. The frontend +never maintains a rival copy of this table. + +If you need a new capability fact (a new env key, a native option, a "supports +X" flag): add it to `KnownAcpRuntime` first, expose it on +`AcpRuntimeCatalogEntry`, then project it through the core. Do not shortcut +with a TypeScript lookup table or an id comparison in a component. + +## Rules + +1. **No hardcoded harness-ID checks in render code.** `runtime.id === "claude"` + belongs in `deriveAgentConfigFieldModel` (once, with a named reason), never + in a component. Components ask the field model what exists + (`hasRenderableAgentConfigField`, `getRenderableEffortField`). +2. **Effort reads/writes go through the descriptor.** Use the effort + descriptor's `currentPersistence` key — never a raw + `BUZZ_AGENT_THINKING_EFFORT` literal in UI code. `currentPersistence` is + where the value lives *today*; `targetApplication` is how the harness + *should* receive it. They intentionally differ until PR 2.7 migrates + Goose/Claude — do not "fix" one to match the other without doing the + migration work. +3. **Field absence has a named reason, not a boolean.** Codex effort is + `ownedByModelId`; Claude effort is `deferredUntilNativeOptionsAvailable`. + New absences get new named reasons in `AgentConfigOmission` / + `render` — never a `showX` prop. +4. **The clearing policy is the named types.** `onContextChange: + "resetDependentValues"` (user changed harness/provider → dependent values + reset everywhere) vs `onCatalogMismatch: "explainOnly" | "onboardingCleanup"` + (an async catalog miss never silently erases saved state outside + onboarding's named cleanup). Do not add mutation booleans like + `clearInvalidModel`; extend the policy types. +5. **"Metadata unknown" ≠ "harness lacks the capability".** Passing + `runtime: undefined` to the core means fields won't render. Surfaces must + gate on the runtime catalog query settling (loading/error states) rather + than letting fields silently vanish — see `AgentDefaultsEditor` / + `DefaultConfigStep` for the pattern. +6. **One canonical behavior, disclosure presets for visibility.** Behavior + flags were deliberately killed in #2148 (`CANONICAL_CONFIG_BEHAVIORS`). + Surface differences are expressed via the `disclosure` preset, not new + boolean props. +7. **Onboarding does not change.** `onboarding-agent-defaults.spec.ts` is the + acceptance gate for anything touching the shared renderer. + +## The tests that enforce this + +- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing + policy. Update when the capability model changes. +- `ui/agentConfigFieldsContract.test.mjs` — canonical behaviors + disclosure + presets. If this fails, you probably reintroduced a per-surface flag. +- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior + pin (35 tests). +- Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. + +## Keep this file true + +**If you change how agent configuration is modeled, rendered, persisted, +applied, or cleared — update this file in the same PR.** A rule that no longer +matches the code is worse than no rule; a new pattern that isn't written down +here will be broken by the next agent that never learns it existed. Reviewers: +treat a config-behavior diff without a matching AGENTS.md diff (or an explicit +"no rules changed" note) as incomplete. diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs new file mode 100644 index 0000000000..62d8a61a6f --- /dev/null +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveAgentConfigFieldModel } from "./agentConfigCore.ts"; + +const config = { + env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" }, + model: "test-model", + preferred_runtime: null, + provider: "anthropic", +}; + +function runtime(id, metadata = {}) { + return { + id, + label: id, + avatarUrl: "", + availability: "available", + command: id, + binaryPath: id, + defaultArgs: [], + mcpCommand: null, + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + ...metadata, + }; +} + +function field(model, kind) { + return model.fields.find((candidate) => candidate.kind === kind); +} + +test("Buzz Agent exposes provider, model, and Buzz-owned effort", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope: "global", + }); + + assert.deepEqual( + model.fields.map((item) => item.kind), + ["provider", "model", "effort"], + ); + assert.equal(field(model, "effort").optionSource, "buzzAgentCatalog"); + assert.deepEqual(field(model, "effort").targetApplication, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); +}); + +test("Goose exposes provider, model, and its real effort application key", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { + modelEnvVar: "GOOSE_MODEL", + providerEnvVar: "GOOSE_PROVIDER", + thinkingEnvVar: "GOOSE_THINKING_EFFORT", + }), + scope: "global", + }); + + assert.equal( + field(model, "effort").optionSource, + "legacyProviderModelCatalog", + ); + assert.deepEqual(field(model, "effort").currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.deepEqual(field(model, "effort").targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); +}); + +test("Claude models effort as a deferred native ACP option", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("claude"), + scope: "global", + }); + + assert.deepEqual( + model.fields.map((item) => item.kind), + ["model", "effort"], + ); + assert.equal( + field(model, "effort").render, + "deferredUntilNativeOptionsAvailable", + ); + assert.deepEqual(field(model, "effort").currentPersistence, { + kind: "unavailable", + }); + assert.deepEqual(field(model, "effort").targetApplication, { + kind: "acpConfigOption", + id: "effort", + category: "thought_level", + }); +}); + +test("Codex omits separate effort because model IDs own it", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("codex"), + scope: "global", + }); + + assert.deepEqual( + model.fields.map((item) => item.kind), + ["model"], + ); + assert.deepEqual(model.omissions, [ + { kind: "effort", reason: "ownedByModelId" }, + ]); +}); + +test("catalog mismatch cleanup is named and restricted to onboarding", () => { + const selectedRuntime = runtime("buzz-agent", { + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }); + const onboarding = deriveAgentConfigFieldModel({ + config, + runtime: selectedRuntime, + scope: "onboarding", + }); + const evergreen = deriveAgentConfigFieldModel({ + config, + runtime: selectedRuntime, + scope: "instance", + }); + + assert.deepEqual(onboarding.dependentValuePolicy, { + onContextChange: "resetDependentValues", + onCatalogMismatch: "onboardingCleanup", + }); + assert.deepEqual(evergreen.dependentValuePolicy, { + onContextChange: "resetDependentValues", + onCatalogMismatch: "explainOnly", + }); +}); diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts new file mode 100644 index 0000000000..5827aedfa7 --- /dev/null +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -0,0 +1,193 @@ +import type { + AcpRuntimeCatalogEntry, + GlobalAgentConfig, +} from "@/shared/api/types"; +import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig"; + +export type AgentConfigScope = + | "onboarding" + | "global" + | "definition" + | "instance"; + +export type DependentValuePolicy = { + onContextChange: "resetDependentValues"; + onCatalogMismatch: "explainOnly" | "onboardingCleanup"; +}; + +type NormalizedFieldPersistence = { + kind: "normalizedField"; + field: "provider" | "model"; +}; + +type EnvVarPersistence = { + kind: "envVar"; + key: string; +}; + +type AcpConfigOptionPersistence = { + kind: "acpConfigOption"; + id: string; + category: string; +}; + +type UnavailablePersistence = { + kind: "unavailable"; +}; + +export type AgentConfigFieldDescriptor = + | { + kind: "provider"; + optionSource: "providerCatalog"; + persistence: NormalizedFieldPersistence; + targetApplication: { kind: "envVar"; key: string }; + render: "control"; + value: string | null; + } + | { + kind: "model"; + optionSource: "acpModels"; + persistence: NormalizedFieldPersistence; + targetApplication: + | { kind: "envVar"; key: string } + | { kind: "acpNative" }; + render: "control"; + value: string | null; + } + | { + kind: "effort"; + optionSource: + | "buzzAgentCatalog" + | "legacyProviderModelCatalog" + | "harnessNative"; + currentPersistence: + | EnvVarPersistence + | AcpConfigOptionPersistence + | UnavailablePersistence; + targetApplication: + | { kind: "envVar"; key: string } + | { kind: "acpConfigOption"; id: string; category: string }; + render: "control" | "deferredUntilNativeOptionsAvailable"; + value: string | null; + }; + +export type AgentConfigOmission = { + kind: "effort"; + reason: "ownedByModelId" | "unsupportedByHarness"; +}; + +export type AgentConfigFieldModel = { + fields: AgentConfigFieldDescriptor[]; + omissions: AgentConfigOmission[]; + dependentValuePolicy: DependentValuePolicy; +}; + +function valueFromEnv(config: GlobalAgentConfig, key: string) { + return config.env_vars[key]?.trim() || null; +} + +/** + * Derives the harness-scoped field model consumed by agent config renderers. + * + * The runtime catalog is authoritative for environment-variable application. + * Harness-native ACP options are named here until discovery exposes them to the + * desktop; descriptors marked deferred must not be rendered as generic fields. + */ +export function deriveAgentConfigFieldModel({ + config, + runtime, + scope, +}: { + config: GlobalAgentConfig; + runtime: AcpRuntimeCatalogEntry | undefined; + scope: AgentConfigScope; +}): AgentConfigFieldModel { + const fields: AgentConfigFieldDescriptor[] = []; + const omissions: AgentConfigOmission[] = []; + + if (runtime?.providerEnvVar) { + fields.push({ + kind: "provider", + optionSource: "providerCatalog", + persistence: { kind: "normalizedField", field: "provider" }, + targetApplication: { kind: "envVar", key: runtime.providerEnvVar }, + render: "control", + value: config.provider, + }); + } + + fields.push({ + kind: "model", + optionSource: "acpModels", + persistence: { kind: "normalizedField", field: "model" }, + targetApplication: runtime?.modelEnvVar + ? { kind: "envVar", key: runtime.modelEnvVar } + : { kind: "acpNative" }, + render: "control", + value: config.model, + }); + + if (runtime?.thinkingEnvVar) { + fields.push({ + kind: "effort", + optionSource: + runtime.id === "buzz-agent" + ? "buzzAgentCatalog" + : "legacyProviderModelCatalog", + currentPersistence: { + kind: "envVar", + key: BUZZ_AGENT_THINKING_EFFORT, + }, + targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar }, + render: "control", + value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), + }); + } else if (runtime?.id === "claude") { + fields.push({ + kind: "effort", + optionSource: "harnessNative", + currentPersistence: { kind: "unavailable" }, + targetApplication: { + kind: "acpConfigOption", + id: "effort", + category: "thought_level", + }, + render: "deferredUntilNativeOptionsAvailable", + value: null, + }); + } else { + omissions.push({ + kind: "effort", + reason: + runtime?.id === "codex" ? "ownedByModelId" : "unsupportedByHarness", + }); + } + + return { + fields, + omissions, + dependentValuePolicy: { + onContextChange: "resetDependentValues", + onCatalogMismatch: + scope === "onboarding" ? "onboardingCleanup" : "explainOnly", + }, + }; +} + +export function hasRenderableAgentConfigField( + model: AgentConfigFieldModel, + kind: AgentConfigFieldDescriptor["kind"], +) { + return model.fields.some( + (field) => field.kind === kind && field.render === "control", + ); +} + +export function getRenderableEffortField( + model: AgentConfigFieldModel, +): Extract | undefined { + return model.fields.find( + (field): field is Extract => + field.kind === "effort" && field.render === "control", + ); +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index c3ae1ba586..770c6fea90 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -16,6 +16,11 @@ import type { import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor } from "@/features/agents/ui/EnvVarsEditor"; import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor"; +import { + deriveAgentConfigFieldModel, + getRenderableEffortField, + hasRenderableAgentConfigField, +} from "@/features/agents/lib/agentConfigCore"; import { getBakedProviderInheritLabel, getGlobalModelFallback, @@ -132,12 +137,6 @@ export type AgentConfigFieldsProps = { * match onboarding. */ disclosure?: "full" | "onboarding-essential"; - /** - * Harness-conditional, not part of the disclosure preset: provider choice - * only exists for runtimes that support LLM provider selection. PR 3 - * derives this internally from selectedRuntime and deletes the prop. - */ - showProviderField?: boolean; unstyled?: boolean; useCustomSelect?: boolean; useChevronSelectIcon?: boolean; @@ -156,7 +155,6 @@ export function AgentConfigFields({ placeholderClassName, selectClassName, disclosure = "full", - showProviderField = true, unstyled = false, useCustomSelect = false, useChevronSelectIcon = false, @@ -172,12 +170,29 @@ export function AgentConfigFields({ showUnavailableEffortOptions, } = resolveDisclosure(disclosure); + const fieldModel = React.useMemo( + () => + deriveAgentConfigFieldModel({ + config, + runtime: selectedRuntime, + scope: disclosure === "onboarding-essential" ? "onboarding" : "global", + }), + [config, disclosure, selectedRuntime], + ); + const effortField = getRenderableEffortField(fieldModel); + const effortPersistenceKey = + effortField?.currentPersistence.kind === "envVar" + ? effortField.currentPersistence.key + : null; const bakedProvider = React.useMemo( () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, [bakedEnv], ); const selectedRuntimeId = selectedRuntime?.id ?? ""; - const providerFieldVisible = showProviderField; + const providerFieldVisible = hasRenderableAgentConfigField( + fieldModel, + "provider", + ); const effectiveProvider = providerFieldVisible ? config.provider?.trim() || bakedProvider || "" : ""; @@ -254,7 +269,8 @@ export function AgentConfigFields({ // Evergreen surfaces (Settings, dialogs) edit saved data that may pair with // higher layers (see PR #2148 review thread), so they only act after the // user explicitly edits the provider in this session. - const healOnMount = disclosure === "onboarding-essential"; + const healOnMount = + fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup"; const userEditedProviderRef = React.useRef(false); // Read inside effects via ref so biome's exhaustive-deps stays honest: // refs are stable, and healOnMount is captured at declaration. @@ -295,8 +311,9 @@ export function AgentConfigFields({ selectedRuntimeId, ]); - const currentEffortForAutoClear = - config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? ""; + const currentEffortForAutoClear = effortPersistenceKey + ? (config.env_vars[effortPersistenceKey] ?? "") + : ""; // When the selected harness changes outside this component (Back → setup // page → choose a different harness → Next), the saved model can belong to @@ -315,7 +332,7 @@ export function AgentConfigFields({ } const nextEnvVars = { ...config.env_vars }; - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -325,6 +342,7 @@ export function AgentConfigFields({ onConfigChange, onCustomModelEditingChange, healOnMount, + effortPersistenceKey, ]); // Orphan-model clearing follows the mount-time healing policy above: the @@ -346,7 +364,7 @@ export function AgentConfigFields({ } const nextEnvVars = { ...config.env_vars }; - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; onCustomModelEditingChange(false); onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); }, [ @@ -355,6 +373,7 @@ export function AgentConfigFields({ dependentFieldsDisabled, onConfigChange, onCustomModelEditingChange, + effortPersistenceKey, ]); const { validValues: effortValidForAutoClear } = getProviderEffortConfig( config.provider ?? "", @@ -365,7 +384,7 @@ export function AgentConfigFields({ effortValid: effortValidForAutoClear, onClear: () => { const nextEnvVars = { ...config.env_vars }; - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey]; onConfigChange({ ...config, env_vars: nextEnvVars }); }, }); @@ -423,11 +442,13 @@ export function AgentConfigFields({ } function handleEnvVarsChange(next: Record) { - const effort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT]; - const merged = - effort !== undefined - ? { ...next, [BUZZ_AGENT_THINKING_EFFORT]: effort } - : next; + const effort = effortPersistenceKey + ? config.env_vars[effortPersistenceKey] + : undefined; + const merged = { ...next }; + if (effortPersistenceKey && effort !== undefined) { + merged[effortPersistenceKey] = effort; + } onConfigChange({ ...config, env_vars: merged }); } @@ -481,16 +502,10 @@ export function AgentConfigFields({ : implicitEffortProvider; 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 currentEffort = effortPersistenceKey + ? (config.env_vars[effortPersistenceKey] ?? "") + : ""; + const effortFieldVisible = showEffortField && effortField !== undefined; const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; @@ -684,9 +699,11 @@ export function AgentConfigFields({ onChange={(value) => { const nextEnvVars = { ...config.env_vars }; if (value === "") { - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + if (effortPersistenceKey) + delete nextEnvVars[effortPersistenceKey]; } else { - nextEnvVars[BUZZ_AGENT_THINKING_EFFORT] = value; + if (effortPersistenceKey) + nextEnvVars[effortPersistenceKey] = value; } onConfigChange({ ...config, env_vars: nextEnvVars }); }} diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 224bcd9d6d..fcd2c623fc 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -111,6 +111,11 @@ export function AgentDefaultsEditor({ () => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"), [runtimesQuery.data], ); + const configSurfaceLoading = isLoading || runtimesQuery.isLoading; + const configSurfaceError = + loadError || + runtimesQuery.isError || + (!configSurfaceLoading && buzzAgentRuntime === undefined); function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; @@ -167,12 +172,12 @@ export function AgentDefaultsEditor({ return (
- {isLoading ? ( + {configSurfaceLoading ? (
Loading…
- ) : loadError ? ( + ) : configSurfaceError ? (
Couldn't load agent defaults. Restart the app to try again. @@ -192,7 +197,7 @@ export function AgentDefaultsEditor({ )} {/* Save bar */} - {!isLoading && !loadError && ( + {!configSurfaceLoading && !configSurfaceError && (
{saveState === "saved" && ( diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index bf1dd05d97..23455f47c9 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -129,8 +129,12 @@ function AgentDefaultsSection({ }, [config.preferred_runtime, selectedRuntimes]); const selectedRuntimeId = selectedRuntime?.id ?? config.preferred_runtime ?? ""; - const selectedRuntimeSupportsModelProvider = - runtimeSupportsLlmProviderSelection(selectedRuntimeId); + const configSurfaceLoading = isLoading || runtimesQuery.isLoading; + const configSurfaceError = + runtimesQuery.isError || + (!configSurfaceLoading && + selectedRuntimeIds.length > 0 && + !selectedRuntime); const harnessOptions = React.useMemo( () => selectedRuntimes.map((runtime) => ({ @@ -182,11 +186,15 @@ function AgentDefaultsSection({ return (
- {isLoading ? ( + {configSurfaceLoading ? (
Loading…
+ ) : configSurfaceError ? ( +

+ Couldn't load harness settings. Go back and try again. +

) : (
@@ -226,7 +234,6 @@ function AgentDefaultsSection({ placeholderClassName="text-foreground/70" selectClassName="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95" disclosure="onboarding-essential" - showProviderField={selectedRuntimeSupportsModelProvider} unstyled useCustomSelect /> diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 0898016132..1c6b445e0b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -174,6 +174,9 @@ export type RawAcpRuntimeCatalogEntry = { binary_path: string | null; default_args: string[]; mcp_command: string | null; + model_env_var?: string | null; + provider_env_var?: string | null; + thinking_env_var?: string | null; install_hint: string; install_instructions_url: string; can_auto_install: boolean; @@ -699,6 +702,9 @@ function fromRawAcpRuntimeCatalogEntry( binaryPath: entry.binary_path, defaultArgs: entry.default_args, mcpCommand: entry.mcp_command, + modelEnvVar: entry.model_env_var ?? null, + providerEnvVar: entry.provider_env_var ?? null, + thinkingEnvVar: entry.thinking_env_var ?? null, installHint: entry.install_hint, installInstructionsUrl: entry.install_instructions_url, canAutoInstall: entry.can_auto_install, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 2bac1c18cd..7834794d25 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -517,6 +517,12 @@ export type AcpRuntimeCatalogEntry = { binaryPath: string | null; defaultArgs: string[]; mcpCommand: string | null; + /** Environment variable used to apply the initial model, when supported. */ + modelEnvVar: string | null; + /** Environment variable used to apply the selected LLM provider, when supported. */ + providerEnvVar: string | null; + /** Environment variable used to apply thinking effort, when supported. */ + thinkingEnvVar: string | null; installHint: string; installInstructionsUrl: string; canAutoInstall: boolean; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6386dfe4e5..75a32877bb 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6674,6 +6674,38 @@ async function handleListRelayAgents( return mockRelayAgents.map(cloneRelayAgent); } +function withMockRuntimeConfigMetadata( + runtime: RawAcpRuntimeCatalogEntry, +): RawAcpRuntimeCatalogEntry { + return { + ...runtime, + model_env_var: + "model_env_var" in runtime + ? runtime.model_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MODEL" + : runtime.id === "goose" + ? "GOOSE_MODEL" + : null, + provider_env_var: + "provider_env_var" in runtime + ? runtime.provider_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_PROVIDER" + : runtime.id === "goose" + ? "GOOSE_PROVIDER" + : null, + thinking_env_var: + "thinking_env_var" in runtime + ? runtime.thinking_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_THINKING_EFFORT" + : runtime.id === "goose" + ? "GOOSE_THINKING_EFFORT" + : null, + }; +} + async function handleDiscoverAcpRuntimes( config: E2eConfig | undefined, ): Promise { @@ -6686,9 +6718,9 @@ async function handleDiscoverAcpRuntimes( const configured = config?.mock?.acpRuntimesCatalog; if (configured) { - return configured; + return configured.map(withMockRuntimeConfigMetadata); } - return [ + const defaultCatalog: RawAcpRuntimeCatalogEntry[] = [ { id: "goose", label: "Goose", @@ -6760,6 +6792,7 @@ async function handleDiscoverAcpRuntimes( login_hint: undefined, }, ]; + return defaultCatalog.map(withMockRuntimeConfigMetadata); } async function handleDiscoverAcpAuthMethods(