From d878dbad4eab68c7b636c151027d3094bee26e5d Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 19 Aug 2026 13:53:54 -0400 Subject: [PATCH 01/33] feat(desktop): genericize per-agent effort as one harness-agnostic projection The persisted `record.effort_level` column is the single canonical effort authority for every runtime. One projection (`config_bridge::effort`) resolves the effective launch value over the canonical column AND all sanitized env tiers in the reader's CLEAR order (record-native > column > record-legacy > persona > global > definition > baked), then reduces the launch env to exactly one destination key: Goose emits GOOSE_THINKING_EFFORT, buzz-agent emits BUZZ_AGENT_THINKING_EFFORT, Claude/Codex/keyless-ACP and any unknown runtime emit the retained BUZZ_ACP_EFFORT_LEVEL startup sentinel. Local spawn, remote deploy, and the restart snapshot all consume the single already-projected `descriptor.env`, so a launched process, a remote payload, and a restart badge can never carry two effort authorities or disagree. On the pin->inherit transition the column and runtime clear inside the locked save; the record effort env aliases are stripped at the update boundary AFTER caller `env_vars` is applied, so a same-request env map cannot reintroduce a stale alias. Runtime switches preserve the canonical column (invalid values skip at projection time and fall through to inherited tiers; switching back restores the preference) and clear only stale native/legacy env aliases. Extracts the KNOWN_ACP_RUNTIMES data catalog to `discovery/catalog.rs` and the buzz-agent OpenRouter readiness tests to `readiness/openrouter_tests.rs` to keep both files under the desktop file-size ratchet (extract, never raise). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/agent_config.rs | 19 +- .../src/commands/agent_config_tests.rs | 6 +- .../src/commands/agent_models_update.rs | 26 +- .../src-tauri/src/commands/agents_deploy.rs | 90 ++-- .../src/managed_agents/claude_config/mod.rs | 28 +- .../src/managed_agents/claude_config/tests.rs | 78 +-- .../managed_agents/config_bridge/effort.rs | 288 +++++++++++ .../config_bridge/effort_tests.rs | 471 ++++++++++++++++++ .../src/managed_agents/config_bridge/mod.rs | 20 + .../managed_agents/config_bridge/reader.rs | 104 +++- .../config_bridge/reader_tests.rs | 7 +- .../config_bridge/reader_tests_ext.rs | 274 ++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 150 +----- .../src/managed_agents/discovery/catalog.rs | 150 ++++++ .../src/managed_agents/discovery/overrides.rs | 71 ++- .../discovery/runtime_metadata.rs | 70 +++ .../src/managed_agents/discovery/tests.rs | 57 +-- .../discovery/tests/effort_clear.rs | 188 +++++++ .../src-tauri/src/managed_agents/readiness.rs | 70 +-- .../readiness/tests/openrouter_tests.rs | 57 +++ .../src-tauri/src/managed_agents/runtime.rs | 16 +- .../src/managed_agents/spawn_snapshot.rs | 63 +-- .../spawn_snapshot/tests_ext.rs | 175 ++++--- desktop/src-tauri/src/managed_agents/types.rs | 10 +- desktop/src/features/agents/AGENTS.md | 5 +- .../ui/agentInstanceEditPinning.test.mjs | 50 ++ .../ui/runtimeModelProviderSelection.test.mjs | 37 ++ .../ui/runtimeModelProviderSelection.ts | 31 ++ desktop/src/shared/api/tauriManagedAgents.ts | 9 +- desktop/src/shared/api/types.ts | 4 +- 30 files changed, 2084 insertions(+), 540 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/effort.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/catalog.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs create mode 100644 desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 4df24e6e9ba..a49bd47465f 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -537,14 +537,19 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< /// Persist the canonical startup effort level for a local managed agent. /// -/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the -/// effort a spawn will apply at next session start. The value is stored on the -/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the -/// harness applies it via `session/set_config_option` against the adapter's -/// advertised `thought_level` configId. Pass `None` to clear (adapter default). +/// The panel's EffortPicker calls this directly to set the effort a spawn will +/// apply at next session start. The value is stored on the harness-agnostic +/// `effort_level` record column; at spawn the launch projection +/// (`config_bridge::effort`, invoked from `runtime.rs`) resolves the effective +/// value and emits it under the destination runtime's native key +/// (`GOOSE_THINKING_EFFORT`, `BUZZ_AGENT_THINKING_EFFORT`, or the +/// `BUZZ_ACP_EFFORT_LEVEL` startup sentinel for Claude/Codex and keyless +/// adapters, which apply it via `session/set_config_option` against the +/// adapter's advertised `thought_level` configId). Pass `None` to clear +/// (reverts to the inherited/adapter default). /// -/// Rejects non-local backends: remote agents receive effort through `policy_env` -/// at deploy time (see `agents_deploy.rs`), never this local persistence path — +/// Rejects non-local backends: remote agents receive effort through the launch +/// projection at deploy time (see `agents_deploy.rs`), never this local persistence path — /// so an effort edit against a deployed agent is a caller error, not a silent /// no-op that leaves the panel and the running agent disagreeing. #[tauri::command] diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 13bcb5d4efa..6a8013d9770 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -29,7 +29,7 @@ fn with_no_goose_config(body: impl FnOnce() -> T) -> T { } fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -55,13 +55,15 @@ fn goose_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::GOOSE_EFFORT_NORMALIZATION), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, - } + }; + &RUNTIME } fn agent_record() -> ManagedAgentRecord { diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index bb045b81a24..c0199c440b1 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -115,15 +115,17 @@ pub async fn update_managed_agent( // Harness edit: the persona's runtime is authoritative, so an explicit // `agent_command_override` is persisted ONLY when the user picks a // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit + // "Inherit from persona" sentinel clears the pin, the materialized + // record runtime, AND the per-instance effort override (column here, + // env aliases after `env_vars` is applied below). A name-only edit // (`agent_command == None`) leaves the pin intact. `harness_override` // threads the user's explicit intent — see `apply_agent_command_update` // and `update_time_agent_command_override` for the full resolution // rules. + let mut inherit_transition = false; if let Some(agent_command) = input.agent_command { let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( + inherit_transition = crate::managed_agents::apply_agent_command_update( record, &personas, &agent_command, @@ -136,10 +138,22 @@ pub async fn update_managed_agent( // mcp_command is intentionally not applied here — the effective MCP // command is always catalog-derived (known_acp_runtime at spawn time) // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; + // + // Apply the caller-supplied `env_vars` (validated first), then — only on + // the pin→inherit transition — strip the record effort env aliases. The + // order is load-bearing: stripping AFTER the env replacement is what + // stops a same-request `env_vars` map from reintroducing a stale effort + // alias while the instance inherits its harness. The column was already + // cleared inside `apply_agent_command_update`. See + // `apply_env_vars_then_effort_transition` for the pinned invariant. + if let Some(ref env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(env_vars)?; } + crate::managed_agents::apply_env_vars_then_effort_transition( + record, + input.env_vars, + inherit_transition, + ); // Native provider/model fields are authoritative. Keep the typed marker // derived for new records while retaining legacy typed records for diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 06f57b1dc52..809d1260e66 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -96,13 +96,13 @@ pub(super) fn build_launch_block( }; policy_env.insert(model_key.into(), value.to_string()); } - // I-4: remote parity for persisted startup effort. Mirrors the local spawn - // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into - // PoolStartup.startup_effort and applies it at first session creation via - // resolve_startup_effort(). - if let Some(ref value) = record.effort_level { - policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone()); - } + // Startup effort needs no remote-specific handling: the harness-agnostic + // effort projection already ran inside `resolve_effective_harness_descriptor`, + // so `descriptor.env` (→ `launch.env`, tier 2) carries exactly one effort key + // holding the effective value, with every foreign/legacy/transport effort key + // stripped. Tier 2 later-wins over `policy_env` (tier 1) and no authoritative + // tier-3 key collides with an effort key, so the projected value reaches the + // remote pod verbatim — identical authority to the local spawn. if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); } @@ -119,14 +119,6 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } - // B5 remote parity: when a canonical effort_level is persisted, strip - // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical - // value in policy_env (tier 1). In the k8s three-tier model tier 2 - // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key - // must be absent from tier 2 whenever a canonical value is present. - // When effort_level is None there is no canonical to protect, so user - // env passthrough stands (env may legitimately seed startup effort). - // // B2 remote parity: mirror the local A1 model authority. For a Claude // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL @@ -136,12 +128,15 @@ pub(super) fn build_launch_block( // canonical model. When no canonical model is present, neither key is in // policy_env, so stripping them keeps the remote process free of both — // matching local, where `apply_claude_model_env(None)` removes both. + // + // Effort keys need no stripping here: the projection already reduced + // `descriptor.env` to exactly one effort key holding the effective value, + // so launch.env carries the authority directly (see the effort note above). let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); let strip_key = |k: &str| { - (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) - || (is_claude - && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") - || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) + is_claude + && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") + || k.eq_ignore_ascii_case("ANTHROPIC_MODEL")) }; let launch_env: BTreeMap = descriptor .env @@ -467,19 +462,27 @@ mod tests { } #[test] - fn launch_block_claude_runtime_injects_effort_level_when_set() { - // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env. - let mut record = record(); - record.effort_level = Some("high".to_string()); + fn launch_block_claude_runtime_carries_projected_effort_in_launch_env() { + // Under the harness-agnostic projection, effort no longer rides + // policy_env: `resolve_effective_harness_descriptor` reduces + // `descriptor.env` to exactly one effort key (for a keyless claude + // runtime, the ACP sentinel) holding the effective value, and + // build_launch_block passes that env through to launch.env verbatim. + let record = record(); let descriptor = EffectiveHarnessDescriptor { command: "claude".into(), args: vec![], - env: BTreeMap::new(), + // The single projected effort key the descriptor resolver emits. + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "high".to_string())]), }; let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "the projected effort key must survive into launch.env" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is not a policy_env value under the projection design" ); } @@ -506,26 +509,35 @@ mod tests { /// authoritative. #[test] fn launch_block_canonical_effort_strips_user_env_collision() { + // Remote parity for the authority collision: the canonical column and a + // conflicting user `BUZZ_ACP_EFFORT_LEVEL` both present. The projection + // (run inside `resolve_effective_harness_descriptor`) resolves it — + // canonical `high` wins over the user `low` transport sentinel — and + // build_launch_block carries exactly that one value into launch.env, + // identical to the local spawn path. let mut record = record(); + record.runtime = Some("claude".into()); record.effort_level = Some("high".to_string()); - let descriptor = EffectiveHarnessDescriptor { - command: "claude".into(), - args: vec![], - // User-supplied conflicting value in descriptor.env. - env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), - }; + record + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &Default::default(), + ) + .expect("claude descriptor resolves"); let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); - // Canonical must be in policy_env (tier 1). + // The projected canonical authority is the single effort value carried. assert_eq!( - launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", - "canonical effort must be in policy_env when record.effort_level is Some" + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "canonical effort must win the collision and reach launch.env" ); - // Conflicting user value must be absent from launch.env (tier 2) so it - // cannot shadow the canonical tier-1 value in build_env. + // Effort is not a policy_env value under the projection design. assert!( - launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), - "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present" + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "effort is carried in launch.env, never policy_env" ); } diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs index 647ea56209e..0871544dbc3 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -4,15 +4,10 @@ //! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned //! env so the harness never sees two model authorities simultaneously. //! -//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup -//! effort authority for all local agents. Written after `descriptor.env` so -//! user-supplied entries cannot shadow a persisted canonical value. - -/// The spawn-time env var carrying startup effort. Shared by the spawn -/// application ([`apply_effort_env`]) and the snapshot projection -/// (`spawn_snapshot::effective_effort`) so the value the harness receives and -/// the value the restart badge compares are named from one place. -pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL"; +//! Startup effort is no longer applied here: the harness-agnostic effort +//! projection (`config_bridge::effort`) runs inside the descriptor resolver, so +//! `descriptor.env` already carries exactly one effort key. See that module for +//! the single-authority contract, including the ACP-startup key constant. /// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model` /// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env. @@ -33,21 +28,6 @@ pub fn apply_claude_model_env(command: &mut std::process::Command, effective_mod } } -/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from -/// `effort_level` (or leave it untouched if `None`). -/// -/// Must be called after `descriptor.env` is written so the canonical persisted -/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When -/// `effort_level` is `None` there is no canonical value to assert; the command -/// env is left untouched so a user-supplied value from `descriptor.env` -/// legitimately seeds startup effort. -pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) { - if let Some(e) = effort_level { - command.env(EFFORT_LEVEL_ENV_VAR, e); - } - // None: no canonical value — leave whatever descriptor.env wrote intact. -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs index f6f0f90cb2d..0e596bc72b7 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -1,4 +1,4 @@ -use super::{apply_claude_model_env, apply_effort_env}; +use super::apply_claude_model_env; /// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after /// `apply_claude_model_env`, even if it was set before (dual-authority defect). @@ -54,74 +54,10 @@ fn a1_anthropic_model_removed_when_no_effective_model() { ); } -// ── B5 effort-authority contract tests ────────────────────────────────────── +// ── B5 effort-authority contract ───────────────────────────────────────────── // -// These tests verify that `apply_effort_env`, called after `descriptor.env`, -// makes the canonical persisted effort win over any user-supplied value. - -/// B5 (local): canonical effort wins when user env supplies a conflicting value. -/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low, -/// then apply_effort_env is called with the canonical "high". The canonical value -/// must be what survives in the spawned-child env. -#[test] -fn b5_canonical_effort_wins_over_user_env_collision() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env writing a user-supplied value (the pre-fix - // ordering: effort written before the loop, then loop overwrote it, or - // equivalently: effort written post-loop but with user value also post-loop). - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // Post-loop canonical application — the fix. - apply_effort_env(&mut cmd, Some("high")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "high", - "canonical effort must win over the user-supplied 'low' — B5 authority ordering" - ); -} - -/// B5 (local): when no canonical effort is persisted (effort_level is None), -/// user env passthrough is preserved — the descriptor.env entry seeds startup effort. -/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command), -/// then apply_effort_env(None) is called — user value must survive. -#[test] -fn b5_user_effort_env_survives_when_no_canonical_value() { - let mut cmd = std::process::Command::new("true"); - // Simulate descriptor.env loop having written a user-supplied value first. - cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); - - // No canonical value — apply_effort_env(None) is a no-op so the user - // value already written by the descriptor.env loop survives intact. - apply_effort_env(&mut cmd, None); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "low", - "user-supplied effort must survive when no canonical value is persisted" - ); -} - -/// B5 (local): canonical effort is present in the spawned env even when user -/// env did NOT supply a conflicting value (basic injection contract). -#[test] -fn b5_canonical_effort_injected_when_no_user_collision() { - let mut cmd = std::process::Command::new("true"); - // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env. - apply_effort_env(&mut cmd, Some("medium")); - - let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); - let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); - assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); - assert_eq!( - effort.unwrap().unwrap_or_default(), - "medium", - "canonical effort must be injected when no collision" - ); -} +// Startup-effort application moved out of this module into the single +// harness-agnostic projection (`config_bridge::effort`). Its authority, +// collision, and single-key contract is exercised by +// `config_bridge::effort::tests`; there is no longer a Claude-local effort +// helper to test here. diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs new file mode 100644 index 00000000000..7e7472cae63 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -0,0 +1,288 @@ +//! The single harness-agnostic effort authority (plan-of-record, PR #4625). +//! +//! ## One projection, one destination key, one snapshot leaf +//! +//! [`effort_launch_projection`] resolves the effective startup effort a spawn +//! would apply, over the canonical persisted column (`record.effort_level`) AND +//! the sanitized per-tier env inputs, in the CLEAR authority order: +//! +//! ```text +//! record native(valid) > canonical column(valid) > record legacy(valid) +//! > persona(native, then legacy) > global(native) > definition(native) +//! > baked(native) +//! ``` +//! +//! (The reader adds the live-ACP tier between column and persona and the config +//! file tier at the bottom; the launch projection has neither — a spawn reads +//! neither a running session nor the on-disk harness file.) +//! +//! The **tier-reading** native key is the runtime's real `thinking_env_var` +//! (`None` for Claude/Codex — those have no native key, so the column is the +//! sole authority and a user-supplied `BUZZ_ACP_EFFORT_LEVEL` is transport, not +//! a tier). The **emission** key ([`EffortLaunch::key`]) is +//! `thinking_env_var.unwrap_or(BUZZ_ACP_EFFORT_LEVEL)`: Goose emits +//! `GOOSE_THINKING_EFFORT`, buzz-agent emits `BUZZ_AGENT_THINKING_EFFORT`, +//! Claude/Codex/keyless-ACP and any unknown/custom runtime emit the retained +//! ACP-startup sentinel `BUZZ_ACP_EFFORT_LEVEL`. +//! +//! [`EffortLaunch::suppress`] lists every known native/legacy effort key plus +//! the sentinel; every consumer strips them all first, then emits at most the +//! one `key`. This is what guarantees a launched process, a remote payload, and +//! a restart snapshot can never carry two effort authorities. + +use std::collections::BTreeMap; + +use super::LEGACY_THINKING_EFFORT_KEY; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::KnownAcpRuntime; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +/// The retained ACP-startup transport key. Claude, Codex, keyless ACP adapters, +/// and any unknown/custom runtime route the effective effort through this key +/// (the harness reads it into `PoolStartup.startup_effort`). It is *transport*, +/// never a value-authority tier: a user-supplied entry is suppressed and +/// overwritten by the projected effective value. +pub(crate) const ACP_STARTUP_EFFORT_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +/// The resolved launch effort for one runtime: the single fact every spawn +/// path (local, remote, snapshot) consumes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EffortLaunch { + /// The final effective effort value, normalized for contract runtimes and + /// raw for contract-less ones, resolved over ALL tiers (column + env). + /// `None` when no tier supplies a value the destination can express. + pub value: Option, + /// The destination env key the value is emitted under. + pub key: &'static str, + /// Every effort key to strip from the launch env before emitting `key`. + /// Always includes the sentinel and all known native/legacy effort keys, so + /// no foreign or transport effort key can shadow the projected authority. + pub suppress: Vec<&'static str>, +} + +impl EffortLaunch { + /// Apply the projection to a launch env map: strip every `suppress` key, + /// then emit `key = value` when a value is present. After this call the map + /// holds at most one effort key (`key`), carrying the effective value. + pub(crate) fn apply(&self, env: &mut BTreeMap) { + for k in &self.suppress { + env.remove(*k); + } + if let Some(ref v) = self.value { + env.insert(self.key.to_string(), v.clone()); + } + } +} + +/// Resolve the single harness-agnostic effort authority and apply it to a fully +/// layered launch `env`: strip every known/legacy/transport effort key, then +/// emit exactly the one destination key holding the effective value. Called by +/// the descriptor resolver AFTER the full layer stack, so the launch env, the +/// remote deploy payload, and the restart snapshot all carry one effort key and +/// one value — no double authority, no foreign key, no launch/badge disagreement. +#[allow(clippy::too_many_arguments)] +pub(crate) fn apply_launch_effort( + env: &mut BTreeMap, + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) { + effort_launch_projection( + record, + runtime, + personas, + record.persona_id.as_deref(), + global_env, + harness_def, + baked_env, + ) + .apply(env); +} + +/// Resolve one effort tier's value, applying within-tier legacy aliasing and +/// normalization. Returns the canonical (or raw, contract-less) value, or +/// `None` when no usable candidate exists. +/// +/// Lookup (per tier, independent of other tiers): +/// 1. Native key — normalized; invalid → skip as absent. +/// 2. Legacy key (`BUZZ_AGENT_THINKING_EFFORT`) — only when the native key +/// differs from it AND `allow_legacy_alias` is set AND the value +/// normalizes. Invalid legacy is skipped so the next tier can supply one. +pub(crate) fn effort_tier_alias( + map: &BTreeMap, + native_key: &str, + norm: impl Fn(&str) -> Option, + allow_legacy_alias: bool, +) -> Option { + if let Some(raw) = map.get(native_key) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + if allow_legacy_alias && native_key != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = map.get(LEGACY_THINKING_EFFORT_KEY) { + if let Some(canonical) = norm(raw) { + return Some(canonical); + } + } + } + None +} + +/// The destination env key the effective effort is emitted under for `runtime`: +/// the runtime's native `thinking_env_var`, else the ACP-startup sentinel +/// (Claude, Codex, keyless ACP adapters, and unknown/custom runtimes). +pub(crate) fn effort_dest_key(runtime: Option<&KnownAcpRuntime>) -> &'static str { + runtime + .and_then(|r| r.thinking_env_var) + .unwrap_or(ACP_STARTUP_EFFORT_KEY) +} + +/// Every effort key to strip before emitting the single destination key: all +/// known native effort keys, the legacy alias, and the ACP-startup sentinel. +/// Stripping the full set guarantees no foreign or transport effort key can +/// shadow the projected authority. +pub(crate) fn effort_suppress_keys() -> Vec<&'static str> { + let mut keys: Vec<&'static str> = super::all_known_effort_keys().collect(); + if !keys.contains(&ACP_STARTUP_EFFORT_KEY) { + keys.push(ACP_STARTUP_EFFORT_KEY); + } + if !keys.contains(&LEGACY_THINKING_EFFORT_KEY) { + keys.push(LEGACY_THINKING_EFFORT_KEY); + } + keys +} + +/// Build the single effective-effort projection for a launch. +/// +/// `global_env`, `persona_id`+`personas`, `harness_def`, and `baked_env` supply +/// the same per-tier inputs the layered spawn env is built from; the projection +/// re-reads them so an invalid high-tier value skips as absent and a lower tier +/// can win (which a merged last-wins env map cannot express). +pub(crate) fn effort_launch_projection( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> EffortLaunch { + let key = effort_dest_key(runtime); + let suppress = effort_suppress_keys(); + + // Normalizer: contract runtimes canonicalize (invalid → skip); contract-less + // runtimes pass raw (any present value is valid for their per-model catalog). + let contract = runtime.and_then(|r| r.effort_normalization); + let norm = |raw: &str| -> Option { + match contract { + Some(c) => c.normalize_str(raw), + None => Some(raw.to_string()), + } + }; + + // Tier-reading native key: the runtime's REAL native key. `None` (Claude, + // Codex, unknown/custom) means there are no env-tier authorities — the + // sentinel in user env is transport only — so the column is the sole source. + let native_key = runtime.and_then(|r| r.thinking_env_var); + + let value = resolve_effective_effort( + record, + native_key, + &norm, + personas, + persona_id, + global_env, + harness_def, + baked_env, + ); + + EffortLaunch { + value, + key, + suppress, + } +} + +/// Resolve the effective effort value in CLEAR authority order (launch tiers). +#[allow(clippy::too_many_arguments)] +fn resolve_effective_effort( + record: &ManagedAgentRecord, + native_key: Option<&str>, + norm: &impl Fn(&str) -> Option, + personas: &[AgentDefinition], + persona_id: Option<&str>, + global_env: &BTreeMap, + harness_def: Option<&HarnessDefinition>, + baked_env: &BTreeMap, +) -> Option { + use crate::managed_agents::env_vars::{is_reserved_env_key, live_persona_env, merged_user_env}; + + // Sanitize env tiers exactly as the layered spawn env does (reserved/ + // malformed/NUL filtering), so the resolved authority matches what launches. + let record_env = merged_user_env(&BTreeMap::new(), &record.env_vars); + + // 1. record native — only for runtimes with a real native key. + if let Some(nk) = native_key { + if let Some(raw) = record_env.get(nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + // 2. canonical column — normalized (raw passthrough for contract-less). + if let Some(raw) = record.effort_level.as_deref() { + if let Some(v) = norm(raw) { + return Some(v); + } + } + // 3. record legacy alias — only when the native key differs from it. + if let Some(nk) = native_key { + if nk != LEGACY_THINKING_EFFORT_KEY { + if let Some(raw) = record_env.get(LEGACY_THINKING_EFFORT_KEY) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + } + } + // Env tiers below require a native key to read. + let nk = native_key?; + + // 4. persona (native, then legacy) — sanitized like the layered spawn env. + let persona_env = merged_user_env(&BTreeMap::new(), &live_persona_env(personas, persona_id)); + if let Some(v) = effort_tier_alias(&persona_env, nk, norm, true) { + return Some(v); + } + // 5. global (native only). + let global = merged_user_env(&BTreeMap::new(), global_env); + if let Some(v) = effort_tier_alias(&global, nk, norm, false) { + return Some(v); + } + // 6. definition (native only) — author-controlled; reserved keys stripped. + if let Some(def) = harness_def { + let def_env: BTreeMap = def + .env + .iter() + .filter(|(k, _)| !is_reserved_env_key(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if let Some(v) = effort_tier_alias(&def_env, nk, norm, false) { + return Some(v); + } + } + // 7. baked build floor (native only). + if let Some(raw) = baked_env.get(nk) { + if let Some(v) = norm(raw) { + return Some(v); + } + } + None +} + +#[cfg(test)] +#[path = "effort_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs new file mode 100644 index 00000000000..e661a3934c2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -0,0 +1,471 @@ +//! Parity matrix for the single harness-agnostic effort projection +//! (`effort_launch_projection`, PR #4625). +//! +//! Every spawn path — local (`runtime.rs`), remote deploy (`agents_deploy.rs`), +//! and restart snapshot (`spawn_snapshot.rs`) — consumes this one projection via +//! `descriptor.env`, so these tests are the authority contract for all three. +//! They pin, per runtime strategy: +//! +//! * the CLEAR authority order (record native > canonical column > record +//! legacy > persona > global > definition > baked); +//! * the decisive mixed-authority case (valid record-native + a different +//! valid column → the native value wins everywhere); +//! * `value == None` when no tier expresses a value the destination accepts; +//! * single-key emission + full-suppress on `apply`; +//! * the unknown/custom-runtime ACP-sentinel fallback. + +use std::collections::BTreeMap; + +use super::{effort_launch_projection, effort_suppress_keys, EffortLaunch}; +use crate::managed_agents::custom_harnesses::HarnessDefinition; +use crate::managed_agents::discovery::{known_acp_runtime_exact, KnownAcpRuntime}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +const GOOSE_KEY: &str = "GOOSE_THINKING_EFFORT"; +const BUZZ_AGENT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; +const ACP_KEY: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +fn goose() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("goose").expect("goose runtime in catalog") +} +fn claude() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("claude").expect("claude runtime in catalog") +} +fn buzz_agent() -> &'static KnownAcpRuntime { + known_acp_runtime_exact("buzz-agent").expect("buzz-agent runtime in catalog") +} + +fn record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "test".to_string(), + name: "Test Agent".to_string(), + persona_id: None, + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + team_catalog_source: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } +} + +fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn persona(id: &str, env_vars: BTreeMap) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "P".to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars, + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn harness_def(env: BTreeMap) -> HarnessDefinition { + HarnessDefinition { + id: "custom".to_string(), + label: "Custom".to_string(), + command: "custom".to_string(), + args: vec![], + env, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// Convenience: project with no persona/global/definition/baked tiers. +fn project_record_only( + record: &ManagedAgentRecord, + runtime: Option<&KnownAcpRuntime>, +) -> EffortLaunch { + effort_launch_projection( + record, + runtime, + &[], + None, + &BTreeMap::new(), + None, + &BTreeMap::new(), + ) +} + +// -------------------------------------------------------------------------- +// Destination key + emission strategy per runtime +// -------------------------------------------------------------------------- + +#[test] +fn goose_emits_only_goose_key() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, GOOSE_KEY); +} + +#[test] +fn claude_routes_canonical_through_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(claude())); + // Claude has no native key: the column is the sole authority and it emits + // under the retained ACP-startup sentinel. + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +#[test] +fn buzz_agent_passes_raw_contract_less_value_under_native_key() { + let mut r = record(); + // buzz-agent has no static normalization contract: a per-model value that + // Goose would reject (e.g. "minimal") passes through raw. + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!(launch.value.as_deref(), Some("minimal")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); +} + +#[test] +fn unknown_runtime_falls_back_to_acp_sentinel() { + let mut r = record(); + r.effort_level = Some("high".into()); + // No runtime metadata (custom/unknown adapter): preserve main's behavior — + // canonical routes through the raw ACP sentinel path. + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// CLEAR authority order + the decisive mixed-authority case +// -------------------------------------------------------------------------- + +#[test] +fn decisive_record_native_outranks_a_different_valid_column() { + // The mixed-authority pin Thufir/Will require: a valid record-native env + // key and a DIFFERENT valid canonical column must resolve to the + // record-native value — reader, local, remote, and snapshot all agree. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "record-native env outranks the canonical column" + ); +} + +#[test] +fn canonical_column_wins_when_no_record_native() { + // No record-native key present: the column is the next tier and wins over + // lower tiers (here, persona). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("high".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn record_legacy_alias_wins_over_persona_for_goose() { + // Record legacy `BUZZ_AGENT_THINKING_EFFORT` outranks persona for a runtime + // whose native key differs from the legacy key. + let mut r = record(); + r.persona_id = Some("p".into()); + r.env_vars = env(&[(BUZZ_AGENT_KEY, "max")]); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "low")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn persona_then_global_then_definition_then_baked_fall_through() { + // With no record tier set, each lower tier wins in order once the ones + // above it are absent. Verify persona > global by presence. + let mut r = record(); + r.persona_id = Some("p".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let global = env(&[(GOOSE_KEY, "low")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "persona outranks global" + ); + + // Drop the persona value: global wins. + let personas = vec![persona("p", BTreeMap::new())]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &global, + None, + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "global outranks definition" + ); + + // Drop global too: definition wins. + let def = harness_def(env(&[(GOOSE_KEY, "medium")])); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + Some(&def), + &BTreeMap::new(), + ); + assert_eq!( + launch.value.as_deref(), + Some("medium"), + "definition outranks baked" + ); + + // Drop definition: baked build floor wins. + let baked = env(&[(GOOSE_KEY, "off")]); + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &baked, + ); + assert_eq!(launch.value.as_deref(), Some("off")); +} + +// -------------------------------------------------------------------------- +// Normalization + skip-as-absent fall-through +// -------------------------------------------------------------------------- + +#[test] +fn goose_alias_column_xhigh_normalizes_to_max() { + let mut r = record(); + r.effort_level = Some("xhigh".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value.as_deref(), Some("max")); +} + +#[test] +fn invalid_goose_column_skips_and_falls_through_to_persona() { + // "minimal" is invalid for Goose: it skips as absent so the persona tier + // supplies the effective value (nondestructive switch policy relies on this). + let mut r = record(); + r.persona_id = Some("p".into()); + r.effort_level = Some("minimal".into()); + let personas = vec![persona("p", env(&[(GOOSE_KEY, "high")]))]; + let launch = effort_launch_projection( + &r, + Some(goose()), + &personas, + Some("p"), + &BTreeMap::new(), + None, + &BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + +#[test] +fn invalid_goose_value_with_no_lower_tier_is_none() { + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value, None, + "invalid canonical with no fallback → None" + ); +} + +#[test] +fn no_tier_set_is_none() { + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); +} + +// -------------------------------------------------------------------------- +// Suppression + single-key emission (the double-authority guard) +// -------------------------------------------------------------------------- + +#[test] +fn suppress_covers_all_native_legacy_and_sentinel_keys() { + let keys = effort_suppress_keys(); + assert!(keys.contains(&GOOSE_KEY), "goose native key suppressed"); + assert!( + keys.contains(&BUZZ_AGENT_KEY), + "buzz-agent native + legacy key suppressed" + ); + assert!(keys.contains(&ACP_KEY), "ACP transport sentinel suppressed"); +} + +#[test] +fn apply_strips_every_foreign_effort_key_then_emits_one() { + // A launch env carrying multiple stale/foreign effort keys must end with + // exactly the one destination key holding the projected value. + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + + let mut launch_env = env(&[ + (ACP_KEY, "stale"), + (BUZZ_AGENT_KEY, "stale"), + (GOOSE_KEY, "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get(ACP_KEY), None); + assert_eq!(launch_env.get(BUZZ_AGENT_KEY), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); + let effort_keys = launch_env + .keys() + .filter(|k| effort_suppress_keys().contains(&k.as_str())) + .count(); + assert_eq!(effort_keys, 1, "exactly one effort key survives"); +} + +#[test] +fn apply_with_no_value_strips_all_effort_keys() { + // value == None → strip every effort key and emit nothing (valid passthrough + // does not survive because the projection already resolved all tiers). + let launch = project_record_only(&record(), Some(goose())); + assert_eq!(launch.value, None); + let mut launch_env = env(&[(ACP_KEY, "x"), (GOOSE_KEY, "y")]); + launch.apply(&mut launch_env); + assert!( + launch_env + .keys() + .all(|k| !effort_suppress_keys().contains(&k.as_str())), + "no effort key remains when the projection has no value" + ); +} + +#[test] +fn buzz_agent_generic_column_does_not_leak_acp_sentinel() { + // A buzz-agent descriptor carrying the generic ACP sentinel in user env must + // launch with only its native key — the sentinel is suppressed as transport. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high")]); + r.effort_level = Some("medium".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + // buzz-agent's native key is BUZZ_AGENT_THINKING_EFFORT; the ACP sentinel is + // not its native tier, so the column wins and emits under the native key. + assert_eq!(launch.value.as_deref(), Some("medium")); + assert_eq!(launch.key, BUZZ_AGENT_KEY); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY), + None, + "ACP sentinel stripped for buzz-agent" + ); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY).map(String::as_str), + Some("medium") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72f..9ac2e5bc10f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -1,6 +1,7 @@ mod buzz_agent; mod claude; mod codex; +pub(crate) mod effort; mod goose; pub(crate) mod reader; mod schema_walker; @@ -8,6 +9,25 @@ pub(crate) mod types; pub(crate) use types::*; +/// The legacy effort env key written by pre-migration saves. +/// +/// Harnesses whose native `thinking_env_var` differs from this constant +/// (currently: Goose uses `GOOSE_THINKING_EFFORT`) need the alias resolver in +/// [`effort`] to translate old saves. buzz-agent's native key equals this +/// constant, so no aliasing applies there. +pub(crate) const LEGACY_THINKING_EFFORT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT"; + +/// Return all known native thinking-effort env keys across all runtimes. +/// +/// Derived from `KNOWN_ACP_RUNTIMES::thinking_env_var` so that adding a new +/// runtime automatically participates in foreign-key suppression without a +/// separate constant to update. +pub(crate) fn all_known_effort_keys() -> impl Iterator { + crate::managed_agents::discovery::KNOWN_ACP_RUNTIMES + .iter() + .filter_map(|rt| rt.thinking_env_var) +} + /// Read the goose harness config file (`~/.config/goose/config.yaml`). /// /// Used by readiness evaluation to silence requirements that are already diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 93827635e90..c6d59628e8f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -1,7 +1,10 @@ +use crate::managed_agents::discovery::EffortNormalization; use crate::managed_agents::discovery::KnownAcpRuntime; use crate::managed_agents::types::ManagedAgentRecord; +use super::effort::effort_tier_alias; use super::types::*; +use super::LEGACY_THINKING_EFFORT_KEY; /// Build the full config surface for an agent, merging all tiers. /// @@ -40,6 +43,7 @@ pub(crate) fn read_config_surface( let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var); let provider_locked = runtime_meta.is_some_and(|m| m.provider_locked); let thinking_env_var = runtime_meta.and_then(|m| m.thinking_env_var); + let effort_norm = runtime_meta.and_then(|m| m.effort_normalization); let supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config); let required_fields: &[&str] = runtime_meta .map(|m| m.required_normalized_fields) @@ -93,6 +97,7 @@ pub(crate) fn read_config_surface( &acp_effort, effort_option.map(|o| o.config_id.as_str()), thinking_env_var, + effort_norm, is_pre_spawn, tiers, ), @@ -542,40 +547,85 @@ fn build_thinking_field( acp_effort: &Option, effort_config_id: Option<&str>, thinking_env_var: Option<&str>, + effort_norm: Option<&'static EffortNormalization>, is_pre_spawn: bool, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: - // record env > record.effort_level (canonical Buzz-persisted) > ACP > - // persona env > global env > definition env > config file. + // Tier ordering (mirrors the launch projection in `config_bridge::effort`, + // plus the two reader-only tiers the projection has no input for — live ACP + // and the on-disk config file): + // record native > canonical column > record legacy > ACP > + // persona > global > definition > config file. // - // `record.effort_level` is the B5 canonical value: the effort a spawn will - // actually apply at next session start (via `apply_effort_env`). Sitting it - // above ACP means the panel shows the *configured* value the agent will - // launch with rather than a stale live-session reading — the record can't - // be masked by, nor mask, the running value silently. - let [rec_env, pers_env, glob_env, def_env] = thinking_env_var - .map(|k| { - env_candidates( - k, - &record.env_vars, - &tiers.persona_env, - &tiers.global_env, - &tiers.definition_env, - ) - }) - .unwrap_or([None, None, None, None]); + // Every candidate is normalized through the runtime's declared contract + // (`effort_norm`) before validity, precedence, override tracking, and the B + // same-value collapse — the SAME normalizer the launch projection applies — + // so the panel and the next spawn resolve one effective value AND authority. + // For contract runtimes an invalid value (e.g. Goose `minimal`) normalizes + // to `None` and is skipped as absent so a lower tier can win; aliases + // (`none`→`off`, `xhigh`→`max`, case-fold) canonicalize. Contract-less + // runtimes (buzz-agent, Claude/Codex column) pass raw. + let norm = |raw: &str| -> Option { + match effort_norm { + Some(c) => c.normalize_str(raw), + None => Some(raw.to_string()), + } + }; - let canonical_effort = record.effort_level.as_deref(); + // Record tiers, split exactly as the projection resolves them: native env + // strictly above the canonical column, legacy env strictly below it. + let rec_native = thinking_env_var + .and_then(|k| record.env_vars.get(k)) + .and_then(|v| norm(v)); + let column = record.effort_level.as_deref().and_then(&norm); + let rec_legacy = thinking_env_var + .filter(|k| *k != LEGACY_THINKING_EFFORT_KEY) + .and_then(|_| record.env_vars.get(LEGACY_THINKING_EFFORT_KEY)) + .and_then(|v| norm(v)); + + // Inherited env tiers: persona resolves native-then-legacy; global and + // definition are native-only (legacy alias excluded), matching the launch + // projection's per-tier alias policy. + let pers = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.persona_env, k, norm, true)); + let glob = thinking_env_var.and_then(|k| effort_tier_alias(&tiers.global_env, k, norm, false)); + let def = + thinking_env_var.and_then(|k| effort_tier_alias(&tiers.definition_env, k, norm, false)); + let file = file_effort.as_deref().and_then(&norm); + + // Live ACP value, normalized (invalid → skip as absent). The matched + // `config_id` is preserved for `write_via` regardless of value validity. + let acp_norm = acp_effort.as_deref().and_then(norm); + + // B same-value collapse: when NO record-level authority exists and the live + // ACP value exactly equals what inheritance would already resolve to, drop + // ACP so the panel shows the true baseline origin ("Global default") rather + // than a spurious "Runtime override (this session only)" — the session is + // almost certainly echoing what spawn injected. When a record tier is + // present it wins over ACP anyway, so ACP stays only for override tracking. + let record_present = rec_native.is_some() || column.is_some() || rec_legacy.is_some(); + let baseline_first = [ + pers.as_deref(), + glob.as_deref(), + def.as_deref(), + file.as_deref(), + ] + .into_iter() + .flatten() + .next(); + let acp_for_list = match (record_present, acp_norm.as_deref(), baseline_first) { + (false, Some(a), Some(b)) if a == b => None, + _ => acp_norm.as_deref(), + }; let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ - (rec_env, ConfigOrigin::BuzzExplicit), - (canonical_effort, ConfigOrigin::BuzzExplicit), - (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), - (pers_env, ConfigOrigin::PersonaDefault), - (glob_env, ConfigOrigin::GlobalDefault), - (def_env, ConfigOrigin::HarnessDefault), - (file_effort.as_deref(), ConfigOrigin::ConfigFile), + (rec_native.as_deref(), ConfigOrigin::BuzzExplicit), + (column.as_deref(), ConfigOrigin::BuzzExplicit), + (rec_legacy.as_deref(), ConfigOrigin::BuzzExplicit), + (acp_for_list, ConfigOrigin::AcpConfigOption), + (pers.as_deref(), ConfigOrigin::PersonaDefault), + (glob.as_deref(), ConfigOrigin::GlobalDefault), + (def.as_deref(), ConfigOrigin::HarnessDefault), + (file.as_deref(), ConfigOrigin::ConfigFile), ]; let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 5fe86e9cf8d..56c49868526 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -28,7 +28,7 @@ fn with_goose_path_root(value: Option<&str>, body: impl FnOnce() -> T) -> T { } fn test_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { + static RUNTIME: KnownAcpRuntime = KnownAcpRuntime { id: "goose", label: "Goose", commands: &["goose"], @@ -54,13 +54,15 @@ fn test_runtime() -> &'static KnownAcpRuntime { config_file_format: Some("yaml"), supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&crate::managed_agents::discovery::GOOSE_EFFORT_NORMALIZATION), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, - } + }; + &RUNTIME } fn test_record() -> ManagedAgentRecord { @@ -646,6 +648,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { config_file_format: None, supports_acp_native_config: false, thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index f86793f91a1..1e14ac5ad0f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -518,3 +518,277 @@ fn claude_default_config_dir_reports_static_settings_path() { .as_deref() .is_some_and(|p| !p.starts_with('~'))); } + +// ── Goose-contract reader normalization + reader/projection parity ──────────── +// +// The reader (`build_thinking_field`) and the launch projection +// (`effort_launch_projection`) must resolve one effective value AND one +// authority for every record/inherited input, or the config panel displays a +// different effort than the next spawn launches. `test_runtime()` is Goose with +// `effort_normalization = GOOSE_EFFORT_NORMALIZATION`, so these exercise the +// normalization gate, alias canonicalization, invalid-value skip/fallthrough, +// and the decisive mixed-authority case — the phase-1 behavior block, not just +// fixture metadata. + +use crate::managed_agents::config_bridge::effort::effort_launch_projection; + +/// Drive the projection from the SAME record + global env the reader sees, so +/// the two resolvers are compared on identical inputs. Persona/definition tiers +/// use distinct input shapes across the two layers and are covered separately; +/// record-native/column/legacy and global are expressible identically here, +/// which is exactly where the authority-order contract is decisive. +fn projection_value( + record: &ManagedAgentRecord, + global_env: &BTreeMap, +) -> Option { + effort_launch_projection( + record, + Some(test_runtime()), + &[], + None, + global_env, + None, + &BTreeMap::new(), + ) + .value +} + +/// Goose invalid record-native value (`minimal` — not in the Goose contract) +/// skips as absent so a valid lower tier wins, IDENTICALLY in reader and +/// projection. This is Thufir's named regression: a raw winner in the panel +/// while the launch skips it. +#[test] +fn goose_invalid_record_native_skips_to_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "minimal".to_string()); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("valid column must win when native is invalid"); + // Reader: invalid native skipped, column wins. + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Projection agrees on value. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Goose alias canonicalization: `xhigh` → `max` in BOTH resolvers (record +/// native), `none` → `off` (column). +#[test] +fn goose_aliases_canonicalize_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "xhigh".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); + + let mut record2 = test_record(); + record2.effort_level = Some("none".to_string()); + let surface2 = read_config_surface(&record2, Some(runtime), None, &no_tiers(), None); + assert_eq!( + surface2 + .normalized + .thinking_effort + .unwrap() + .value + .as_deref(), + Some("off") + ); + assert_eq!( + projection_value(&record2, &BTreeMap::new()).as_deref(), + Some("off") + ); +} + +/// The decisive mixed-authority case (Thufir/Paul acceptance pin): a valid +/// record-native value and a DIFFERENT valid column → the native value wins in +/// reader and projection alike. The column is the surfaced override baseline. +#[test] +fn goose_record_native_outranks_column_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + record.effort_level = Some("low".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // Column is the overridden baseline (next distinct tier below native). + assert_eq!(effort.overridden_value.as_deref(), Some("low")); + // Projection resolves the same authority. + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} + +/// Invalid column AND invalid native → both skip; a valid global tier wins in +/// the reader, and the projection (driven from the same global env) agrees. +#[test] +fn goose_invalid_record_tiers_fall_through_to_global_in_reader_and_projection() { + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "bogus".to_string()); + record.effort_level = Some("alsobad".to_string()); + let runtime = test_runtime(); + let mut global = BTreeMap::new(); + global.insert("GOOSE_THINKING_EFFORT".to_string(), "medium".to_string()); + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + let effort = surface + .normalized + .thinking_effort + .expect("global tier must win when both record tiers are invalid"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::GlobalDefault); + assert_eq!( + projection_value(&record, &global).as_deref(), + Some("medium") + ); +} + +/// Goose legacy alias (`BUZZ_AGENT_THINKING_EFFORT`) is accepted for the record +/// tier below the column, canonicalized, in reader and projection alike. +#[test] +fn goose_record_legacy_alias_below_column_in_reader_and_projection() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "xhigh".to_string(), + ); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("record legacy alias must surface when native and column are absent"); + assert_eq!(effort.value.as_deref(), Some("max")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("max") + ); +} + +/// B same-value collapse: no record authority, live ACP echoes the inherited +/// global value → the panel shows the inherited origin (GlobalDefault), not a +/// spurious per-session AcpConfigOption override. +#[test] +fn goose_acp_equal_to_global_collapses_to_global_origin() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("medium".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!( + effort.origin, + ConfigOrigin::GlobalDefault, + "ACP echoing the inherited value must not masquerade as a session override" + ); +} + +/// B same-value collapse does NOT fire on genuine divergence: live ACP differs +/// from the inherited baseline → ACP wins as the per-session override, global +/// is the surfaced baseline. +#[test] +fn goose_acp_diverging_from_global_wins_as_override() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("low")); + assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption); + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +/// Invalid live ACP value is skipped as absent; a valid record tier wins and +/// no phantom ACP override is surfaced. +#[test] +fn goose_invalid_acp_skips_and_record_wins() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("garbage".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!( + projection_value(&record, &BTreeMap::new()).as_deref(), + Some("high") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 78592357c9b..8db4f27be44 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -15,6 +15,8 @@ mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +mod catalog; +pub(crate) use catalog::KNOWN_ACP_RUNTIMES; pub use login_shell::{find_nvm_default_bin, login_shell_path}; pub(crate) use login_shell::{find_via_login_shell, refresh_login_shell_path}; #[cfg(test)] @@ -26,7 +28,10 @@ pub(crate) use presets::{ preset_harness_ids, }; use presets::{preset_catalog_entry, PRESET_HARNESSES}; +pub(crate) use runtime_metadata::EffortNormalization; pub(crate) use runtime_metadata::KnownAcpRuntime; +#[cfg(test)] +pub(crate) use runtime_metadata::GOOSE_EFFORT_NORMALIZATION; 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"; @@ -83,144 +88,6 @@ fn common_binary_paths() -> &'static [PathBuf] { }) } -const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ - KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - // Goose's stable release currently publishes only the Unix installer; - // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], - adapter_install_commands: &[], - cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", - adapter_install_instructions_url: "", - cli_install_hint: "Buzz talks to Goose through the Goose CLI.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - max_rounds_env_var: None, - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, - KnownAcpRuntime { - id: "claude", - label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], - avatar_url: CLAUDE_CODE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", - skill_dir: Some(".claude/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: Some("~/.claude/settings.json"), - config_file_format: Some("json"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - max_rounds_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), - auth_probe_args: Some(&["claude", "auth", "status"]), - }, - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: &["codex-acp"], - aliases: &[], - avatar_url: CODEX_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - cli_install_instructions_url: "https://developers.openai.com/codex/cli/", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", - skill_dir: Some(".codex/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.codex/config.toml"), - config_file_format: Some("toml"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - max_rounds_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run `codex login` to authenticate."), - // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. - auth_probe_args: Some(&["codex", "login", "status"]), - }, - KnownAcpRuntime { - id: "buzz-agent", - label: "Buzz Agent", - commands: &["buzz-agent"], - aliases: &[], - avatar_url: BUZZ_AGENT_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: true, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "https://github.com/block/buzz", - adapter_install_instructions_url: "https://github.com/block/buzz", - cli_install_hint: "Ships with the Buzz desktop app.", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: true, - model_env_var: Some("BUZZ_AGENT_MODEL"), - provider_env_var: Some("BUZZ_AGENT_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), - max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), - context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), - max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, -]; - /// Skill discovery directories declared by known runtimes. pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) @@ -375,7 +242,12 @@ pub fn effective_agent_command( } mod overrides; -pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +#[cfg(test)] +pub use overrides::remove_record_effort_aliases; +pub use overrides::{ + apply_agent_command_update, apply_env_vars_then_effort_transition, + create_time_agent_command_override, +}; /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs new file mode 100644 index 00000000000..1452666cfe1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -0,0 +1,150 @@ +//! The known-ACP-runtime catalog. Extracted from `discovery.rs` as pure data +//! (mirroring `presets::PRESET_HARNESSES`) so the module stays under the +//! file-size ratchet. The `windows_install_command!` macro is in textual scope +//! here because this module is declared after `#[macro_use] mod windows_install` +//! in the parent. + +use super::runtime_metadata::{KnownAcpRuntime, GOOSE_EFFORT_NORMALIZATION}; +use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; + +pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], + adapter_install_commands: &[], + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, + KnownAcpRuntime { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + aliases: &["claude-code", "claudecode"], + avatar_url: CLAUDE_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", + adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + skill_dir: Some(".claude/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // claude: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run the Claude CLI to complete authentication."), + auth_probe_args: Some(&["claude", "auth", "status"]), + }, + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + aliases: &[], + avatar_url: CODEX_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], + adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz talks to Codex through the Codex CLI.", + adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + skill_dir: Some(".codex/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + effort_normalization: None, // codex: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `codex login` to authenticate."), + // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. + auth_probe_args: Some(&["codex", "login", "status"]), + }, + KnownAcpRuntime { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + aliases: &[], + avatar_url: BUZZ_AGENT_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", + cli_install_hint: "Ships with the Buzz desktop app.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: Some("BUZZ_AGENT_MODEL"), + provider_env_var: Some("BUZZ_AGENT_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + effort_normalization: None, // buzz-agent: per-model catalog; see getProviderEffortConfig() in TS + max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdda..868f5009406 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -83,27 +83,82 @@ pub fn update_time_agent_command_override( /// Apply an explicit `agent_command` edit to `record`: persist the override /// pin decided by [`update_time_agent_command_override`], and on the inherit /// sentinel (empty/whitespace command) also clear the materialized -/// `record.runtime` so the resolution ladder falls through to the live -/// definition immediately instead of silently keeping the stale instance copy. +/// `record.runtime` AND the persisted per-instance effort column so the +/// resolution ladder falls through to the live definition immediately instead +/// of silently keeping the stale instance copy. /// -/// The runtime clear is guarded on a live persona link: for a definition-less -/// record the materialized runtime is the only harness source left after the -/// override clear, so a stray empty `agent_command` from a non-dialog caller -/// must not change what the agent runs. +/// The clears are guarded on a live persona link: for a definition-less record +/// the materialized runtime is the only harness source left after the override +/// clear, so a stray empty `agent_command` from a non-dialog caller must not +/// change what the agent runs. +/// +/// Returns `true` when the pin→inherit transition fired. The caller MUST then, +/// AFTER applying any caller-supplied `env_vars`, strip the record effort env +/// aliases via [`remove_record_effort_aliases`] — clearing them here would be +/// undone by a same-request `env_vars` replacement (see the update boundary in +/// `agent_models_update.rs`), so the alias strip is an update-boundary +/// invariant, not a helper-local one. +#[must_use] pub fn apply_agent_command_update( record: &mut crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], agent_command: &str, harness_override: bool, -) { +) -> bool { record.agent_command_override = update_time_agent_command_override( record.persona_id.as_deref(), personas, Some(agent_command), harness_override, ); - if agent_command.trim().is_empty() && record.persona_id.is_some() { + let inherit_transition = agent_command.trim().is_empty() && record.persona_id.is_some(); + if inherit_transition { record.runtime = None; + // The generic canonical effort column is a per-instance pin; on the + // pin→inherit transition it is dropped so the agent inherits the + // persona/global effort. The record effort ENV aliases are stripped by + // the caller after `env_vars` is applied (see the doc above). + record.effort_level = None; + } + inherit_transition +} + +/// Strip every record-level thinking-effort env alias — all known native keys +/// plus the legacy `BUZZ_AGENT_THINKING_EFFORT` alias — from `env_vars`. +/// +/// Called at the `update_managed_agent` boundary on the pin→inherit transition, +/// AFTER caller-supplied `env_vars` have been applied, so the cleared aliases +/// cannot be reintroduced by the same request. Together with the column clear +/// in [`apply_agent_command_update`], this makes the instance drop its entire +/// per-instance effort override atomically at Save. +pub fn remove_record_effort_aliases(env_vars: &mut std::collections::BTreeMap) { + for key in crate::managed_agents::config_bridge::effort::effort_suppress_keys() { + env_vars.remove(key); + } +} + +/// Apply a same-request `env_vars` replacement and then enforce the pin→inherit +/// effort-alias strip, in that exact order. +/// +/// This is the ordering invariant Thufir's plan-of-record pins: the effort +/// column is cleared eagerly inside [`apply_agent_command_update`], but a stale +/// effort env alias in a caller-supplied `env_vars` map submitted in the SAME +/// request would otherwise survive the transition. Applying `env_vars` first, +/// then stripping the aliases only on the transition, guarantees the instance +/// cannot re-pin effort through the generic env channel while inheriting its +/// harness. `env_vars = None` leaves the record's existing env untouched; +/// validation of the supplied map is the caller's responsibility (it runs +/// before this seam at the update boundary). +pub fn apply_env_vars_then_effort_transition( + record: &mut crate::managed_agents::types::ManagedAgentRecord, + env_vars: Option>, + inherit_transition: bool, +) { + if let Some(env_vars) = env_vars { + record.env_vars = env_vars; + } + if inherit_transition { + remove_record_effort_aliases(&mut record.env_vars); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 34edecdcd9c..af6280a208e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -1,3 +1,56 @@ +/// Canonicalization contract for a harness's thinking-effort env var. +/// +/// The single value authority shared by UI choices, the spawn/deploy launch +/// projection, and the reader. All effort candidates (native env, legacy env, +/// ACP tier, file tier) are normalized through `normalize_str` before any +/// validity, precedence, override, or B-equality check. +/// +/// Source for Goose: `crates/goose-provider-types/src/thinking.rs` +/// • `FromStr` (aliases, case-insensitive): `off|disabled|none`, `low`, +/// `medium|med`, `high`, `max|xhigh` +/// • `Display` (canonical): `off`, `low`, `medium`, `high`, `max` +/// • Live ACP emits Display values via `response_builder.rs:326-337`. +pub(crate) struct EffortNormalization { + /// Canonical values in UI display order (drive choices, persistence, ACP comparison). + pub canonical: &'static [&'static str], + /// `(alias, canonical)` pairs, case-insensitive. Only aliases that differ + /// from their canonical form are listed. + pub aliases: &'static [(&'static str, &'static str)], +} + +/// Goose thinking-effort canonicalization contract. +/// +/// Source: `crates/goose-provider-types/src/thinking.rs` at Goose `2db0e31fe`. +/// Canonical Display values: `off`, `low`, `medium`, `high`, `max`. +/// Aliases (case-insensitive): `none|disabled→off`, `med→medium`, `xhigh→max`. +/// `minimal` (Buzz-only) is invalid — skipped as absent at every tier. +pub(crate) static GOOSE_EFFORT_NORMALIZATION: EffortNormalization = EffortNormalization { + canonical: &["off", "low", "medium", "high", "max"], + aliases: &[ + ("none", "off"), + ("disabled", "off"), + ("med", "medium"), + ("xhigh", "max"), + ], +}; + +impl EffortNormalization { + /// Normalize `raw` to canonical form. `None` → invalid for this harness; + /// the caller must treat it as absent (skip-as-absent policy). + pub fn normalize_str(&self, raw: &str) -> Option { + let lower = raw.to_lowercase(); + if self.canonical.contains(&lower.as_str()) { + return Some(lower); + } + for &(alias, canon) in self.aliases { + if lower == alias { + return Some(canon.to_string()); + } + } + None + } +} + /// Static capabilities and installation metadata for a known ACP runtime. pub(crate) struct KnownAcpRuntime { pub id: &'static str, @@ -47,6 +100,23 @@ pub(crate) struct KnownAcpRuntime { 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>, + /// Canonicalization contract for `thinking_env_var` on this harness. + /// + /// `Some(contract)` — harness uses a finite, static effort vocabulary. + /// All candidates (native env, legacy env, ACP tier, file tier) are + /// normalized through this contract before validity checks, precedence + /// resolution, override tracking, and B-equality comparison. + /// + /// `None` — harness accepts any provider/model-specific value via its own + /// catalog (buzz-agent); see `getProviderEffortConfig()` in TS for that + /// path. Contract-less does NOT mean keyless: buzz-agent still has a native + /// `thinking_env_var`, and Claude/Codex route the canonical through + /// `BUZZ_ACP_EFFORT_LEVEL` for ACP startup even with `thinking_env_var: None`. + /// + /// The single canonical authority shared by UI choices, the launch + /// projection, and the reader. No value-authority logic may live outside + /// this struct for harnesses that declare one. + pub effort_normalization: Option<&'static EffortNormalization>, /// 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>, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index ff5cfc34725..e5c1da7a00c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -2,12 +2,13 @@ use std::path::PathBuf; use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ - apply_agent_command_update, classify_runtime, codex_adapter_availability, - codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag, - managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, - record_agent_command, refresh_login_shell_path, try_record_agent_command, - BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, + codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, + default_agent_command, effective_agent_command, find_nvm_default_bin, + is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, + parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, + remove_record_effort_aliases, try_record_agent_command, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -606,51 +607,9 @@ fn update_time_override_preserves_pin_for_persona_less_agent() { ); } -#[test] -fn apply_agent_command_update_inherit_sentinel_clears_pin_and_runtime() { - // Choosing Inherit on a persona-linked record clears BOTH the explicit - // pin and the materialized runtime, so resolution falls through to the - // live definition immediately — not on the next spawn. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); - - apply_agent_command_update(&mut record, &personas, "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime, None); - assert_eq!(record_agent_command(&record, &personas), "goose"); -} - -#[test] -fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { - // For a record with no persona link the materialized runtime is the only - // harness source left once the pin is cleared — a stray empty - // agent_command must not change what the agent runs. - let mut record = record_with(Some("claude"), None, Some("codex-acp")); - - apply_agent_command_update(&mut record, &[], "", false); - - assert_eq!(record.agent_command_override, None); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); -} - -#[test] -fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { - // A concrete pick only sets the pin; the materialized runtime is left for - // the next snapshot apply. The pin shadows it in resolution either way. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - let mut record = record_with(Some("claude"), Some("p1"), None); - - apply_agent_command_update(&mut record, &personas, "codex-acp", true); - - assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); - assert_eq!(record.runtime.as_deref(), Some("claude")); - assert_eq!(record_agent_command(&record, &personas), "codex-acp"); -} - // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod effort_clear; mod forced_discovery; mod managed_path_resolution; #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs new file mode 100644 index 00000000000..bdeb1a10802 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/effort_clear.rs @@ -0,0 +1,188 @@ +//! Backend tests for the pin→inherit effort clear (PR #4625, plan-of-record +//! item 1): the sentinel transition clears the canonical column eagerly and the +//! update boundary strips the record effort env aliases AFTER caller `env_vars` +//! is applied. Split out of `discovery/tests.rs` to hold that file under the +//! desktop file-size ratchet. +//! +//! `use super::*` pulls the parent test module's helpers (`record_with`, +//! `persona_with_runtime`, `record_agent_command`) and its imported command +//! surface (`apply_agent_command_update`, `apply_env_vars_then_effort_transition`, +//! `remove_record_effort_aliases`). + +use super::*; + +#[test] +fn apply_agent_command_update_inherit_sentinel_clears_pin_runtime_and_column() { + // Choosing Inherit on a persona-linked record clears the explicit pin, the + // materialized runtime, AND the per-instance effort column, so resolution + // falls through to the live definition immediately — not on the next spawn. + // The transition flag fires so the caller strips the record effort env + // aliases after `env_vars` is applied. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + + assert!(transition, "the pin→inherit transition must be signalled"); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime, None); + assert_eq!( + record.effort_level, None, + "the effort column must be cleared" + ); + assert_eq!(record_agent_command(&record, &personas), "goose"); +} + +#[test] +fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { + // For a record with no persona link the materialized runtime is the only + // harness source left once the pin is cleared — a stray empty + // agent_command must not change what the agent runs, nor clear its effort. + let mut record = record_with(Some("claude"), None, Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &[], "", false); + + assert!( + !transition, + "a definition-less stray sentinel is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a definition-less record must preserve its effort column" + ); + assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); +} + +#[test] +fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime_and_column() { + // A concrete pick only sets the pin; the materialized runtime and the + // effort column are left intact (no ownership transition). The pin shadows + // the runtime in resolution either way. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + + assert!( + !transition, + "a concrete pin is not a pin→inherit transition" + ); + assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!( + record.effort_level.as_deref(), + Some("high"), + "a concrete pin must preserve the effort column" + ); + assert_eq!(record_agent_command(&record, &personas), "codex-acp"); +} + +#[test] +fn remove_record_effort_aliases_strips_all_known_and_legacy_keys() { + // The update-boundary alias strip: after `env_vars` is applied on the + // pin→inherit transition, every known native effort key and the legacy + // alias must be removed, while unrelated env survives. This proves the + // second half of the atomic clear that a helper-only column clear cannot. + let mut env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "high"), + ("BUZZ_AGENT_THINKING_EFFORT", "high"), + ("BUZZ_ACP_EFFORT_LEVEL", "high"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + remove_record_effort_aliases(&mut env); + + assert!(!env.contains_key("GOOSE_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_AGENT_THINKING_EFFORT")); + assert!(!env.contains_key("BUZZ_ACP_EFFORT_LEVEL")); + assert_eq!( + env.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env must survive the effort-alias strip" + ); +} + +#[test] +fn update_boundary_inherit_sentinel_with_alias_bearing_env_vars_strips_after_apply() { + // The update-boundary ORDERING invariant (Thufir pass-3): on the pin→inherit + // transition, a SAME-REQUEST `env_vars` map carrying a stale effort alias + // must NOT survive. `apply_agent_command_update` clears the column eagerly; + // then `apply_env_vars_then_effort_transition` applies the caller env FIRST + // and strips the aliases AFTER — so the alias the request tried to + // reintroduce is gone. A helper-only test cannot prove this order. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.effort_level = Some("high".into()); + + let transition = apply_agent_command_update(&mut record, &personas, "", false); + assert!( + transition, + "empty command on a persona-linked record is inherit" + ); + + // The request replaces env_vars with a map that re-pins effort via an alias + // plus an unrelated key. + let request_env: std::collections::BTreeMap = [ + ("GOOSE_THINKING_EFFORT", "max"), + ("BUZZ_ACP_EFFORT_LEVEL", "max"), + ("UNRELATED_KEY", "keep"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!(record.effort_level, None, "column stays cleared"); + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "same-request native alias must not survive the transition" + ); + assert!( + !record.env_vars.contains_key("BUZZ_ACP_EFFORT_LEVEL"), + "same-request ACP sentinel must not survive the transition" + ); + assert_eq!( + record.env_vars.get("UNRELATED_KEY").map(String::as_str), + Some("keep"), + "unrelated env from the same request is preserved" + ); +} + +#[test] +fn update_boundary_concrete_pin_preserves_alias_bearing_env_vars() { + // No transition (concrete pin): the caller `env_vars` — including any effort + // alias — is applied verbatim and NOT stripped. Effort env is only cleared + // on the ownership transition, never on an ordinary env edit. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + + let transition = apply_agent_command_update(&mut record, &personas, "codex-acp", true); + assert!(!transition, "a concrete pin is not a transition"); + + let request_env: std::collections::BTreeMap = + [("GOOSE_THINKING_EFFORT", "max")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + apply_env_vars_then_effort_transition(&mut record, Some(request_env), transition); + + assert_eq!( + record + .env_vars + .get("GOOSE_THINKING_EFFORT") + .map(String::as_str), + Some("max"), + "without a transition the caller effort env is preserved" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 909b97d652d..0b5e7fd9d47 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -269,6 +269,20 @@ fn resolve_effective_agent_env_with_def( ); env.extend(user_env); + // Single harness-agnostic effort authority (PR #4625): resolve effective + // effort over the canonical column AND all env tiers, emit one destination + // key. Runs AFTER the layer stack so launch, remote deploy, and the restart + // snapshot agree — no double authority, no foreign key, no badge disagreement. + super::config_bridge::effort::apply_launch_effort( + &mut env, + record, + runtime, + personas, + &global.env_vars, + harness_def.as_deref(), + &baked_build_env(), + ); + // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] @@ -1049,6 +1063,7 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1241,6 +1256,7 @@ mod tests { default_env: &[], supports_acp_native_config: false, thinking_env_var: None, + effort_normalization: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1681,56 +1697,10 @@ mod tests { })); } - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } + // buzz-agent OpenRouter readiness tests live in a sibling file so this + // module stays under the desktop file-size ratchet. + #[path = "openrouter_tests.rs"] + mod openrouter_tests; } // Goose file-config-aware requirement tests live in a sibling file so this diff --git a/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs new file mode 100644 index 00000000000..73b3fcda4b8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/tests/openrouter_tests.rs @@ -0,0 +1,57 @@ +//! buzz-agent OpenRouter readiness tests, split from `readiness.rs`'s `tests` +//! module so that file stays under the desktop file-size ratchet. +//! +//! Declared as a child of `mod tests` via `#[path]`, so `use super::*` resolves +//! against that module and reaches its `make_env`/`env_with` helpers. + +use super::*; + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..c84a2eb5619 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::claude_config::{apply_claude_model_env, apply_effort_env}; +use super::claude_config::apply_claude_model_env; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; @@ -810,13 +810,13 @@ pub fn spawn_agent_child( command.env(key, value); } - // B5: carry persisted effort; harness resolves thought_level configId at first session. - // Written AFTER descriptor.env so the canonical persisted value wins over any - // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern - // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is - // None there is no canonical value to assert, so env passthrough stands — user env - // legitimately seeds startup effort in that case. - apply_effort_env(&mut command, record.effort_level.as_deref()); + // Effort authority is already resolved: the single harness-agnostic effort + // projection ran inside `resolve_effective_agent_env_with_def`, so + // `descriptor.env` (written above) carries exactly one effort key holding the + // effective value — every foreign/legacy/transport effort key was stripped + // there. Re-applying `apply_effort_env` here would double-write the ACP + // sentinel and, on a Goose descriptor, launch a second effort key alongside + // `GOOSE_THINKING_EFFORT`. No post-loop effort write is needed. // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 8a6f68a693d..55ec314acc6 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -31,7 +31,6 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ - claude_config::EFFORT_LEVEL_ENV_VAR, effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, persona_events::preview_prospective_persona_snapshot, @@ -128,30 +127,30 @@ pub(crate) struct SpawnConfigSnapshot { pub max_turn_duration_seconds: Option, pub parallelism: u32, /// The startup effort the harness will actually apply, resolved by - /// [`effective_effort`]: the persisted canonical `record.effort_level` when - /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered - /// env. This is the *sole* representation of effort in the snapshot — the - /// key is stripped from `env` (see `from_inputs`) so an authority handoff - /// that leaves the effective value unchanged (canonical `low` replacing a - /// user env `low`, or the reverse) produces no spurious drift entry, and an - /// env-only edit still surfaces as exactly one `effort_level` entry. + /// [`effective_effort`]: the single effort key the harness-agnostic + /// projection left in `descriptor.env` under the runtime's destination key. + /// This is the *sole* representation of effort in the snapshot — every + /// effort key is stripped from `env` (see `from_inputs`) so an authority + /// handoff that leaves the effective value unchanged produces no spurious + /// drift entry, and an effort edit surfaces as exactly one `effort_level` + /// entry. pub effort_level: Option, } -/// The startup effort a spawn would actually apply, mirroring `apply_effort_env` -/// exactly: the persisted canonical `record.effort_level` wins, and only when it -/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env -/// seed startup effort. This is the resolver input for the snapshot's single -/// `effort_level` representation; the same precedence runs at spawn time in -/// `runtime.rs`, so badge and process can never disagree. -pub(crate) fn effective_effort( - record: &ManagedAgentRecord, - descriptor_env: &BTreeMap, -) -> Option { - record - .effort_level - .clone() - .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned()) +/// The startup effort a spawn actually applied, read from the single effort key +/// the harness-agnostic projection left in `descriptor.env`. +/// +/// The projection (`config_bridge::effort`) ran inside the descriptor resolver, +/// resolving the effective value over the canonical column and every env tier, +/// then reducing the env to exactly one effort key under the runtime's +/// destination key (`effort_dest_key`). Reading that key here means the badge +/// compares precisely what launched — no separate precedence to drift from the +/// spawn path, and an invalid canonical that fell through to an inherited tier +/// is reflected as the inherited value, not the raw column. +pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Option { + let runtime = known_acp_runtime(&descriptor.command); + let dest_key = super::config_bridge::effort::effort_dest_key(runtime); + descriptor.env.get(dest_key).cloned() } impl SpawnConfigSnapshot { @@ -178,14 +177,16 @@ impl SpawnConfigSnapshot { .unwrap_or("") .to_string(), // Effort has ONE representation in the snapshot: `effort_level` - // below, always holding `effective_effort`. Stripping the env key - // here means a canonical/user-env authority handoff at the same - // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or - // remove) and an env-only effort edit surfaces as exactly one + // below, always holding the projected effective value. Every effort + // key is stripped from `env` (the full suppress set) so an authority + // handoff at the same value is a no-op (no phantom `env.*EFFORT*` add + // or remove) and an env-only effort edit surfaces as exactly one // `effort_level` entry rather than a duplicate under `env.`. env: { let mut env = descriptor.env.clone(); - env.remove(EFFORT_LEVEL_ENV_VAR); + for key in super::config_bridge::effort::effort_suppress_keys() { + env.remove(key); + } env }, relay_url: relay_url.to_string(), @@ -215,10 +216,10 @@ impl SpawnConfigSnapshot { // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), // Sole effort representation — see the field doc and the `env` - // strip above. Resolver reads the record's canonical value and the - // raw descriptor env (before the strip), so a user-seeded env value - // is preserved as the effective effort when no canonical is set. - effort_level: effective_effort(record, &descriptor.env), + // strip above. Reads the single projected effort key the descriptor + // resolver left in `descriptor.env`, so the badge compares exactly + // what launched regardless of which tier supplied the value. + effort_level: effective_effort(descriptor), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index dd708b6e59e..f258b90a6d0 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -27,86 +27,112 @@ fn effort_set_then_cleared_round_trips_to_no_effort_projection() { } #[test] -fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() { - // Canonical `high` shadows the user env seed. Editing that seed low→medium - // changes nothing effective (canonical wins and the env key is stripped), - // so the projections are identical and no badge lights. - let mut low_env = record_with_env_effort("low"); - low_env.effort_level = Some("high".into()); - let mut medium_env = record_with_env_effort("medium"); - medium_env.effort_level = Some("high".into()); +fn canonical_edit_under_record_native_env_is_empty_diff() { + // For Goose, the record-native env key `GOOSE_THINKING_EFFORT` outranks the + // canonical column (CLEAR authority order). With a record-native `low` + // present, editing the shadowed canonical high→medium changes nothing + // effective, so the projections are identical and no badge lights. + let mut high_col = record_with_env_effort("low"); + high_col.effort_level = Some("high".into()); + let mut medium_col = record_with_env_effort("low"); + medium_col.effort_level = Some("medium".into()); assert_eq!( - snap(&low_env), - snap(&medium_env), - "editing a canonical-shadowed user env must not badge" + snap(&high_col), + snap(&medium_col), + "editing a record-native-env-shadowed canonical must not badge" ); } #[test] -fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() { - // Canonical `high` over a user env seed `low`: clearing the canonical drops - // the effective effort to the env fallback `low`, a real change that badges. - let mut canonical = record_with_env_effort("low"); - canonical.effort_level = Some("high".into()); - let env_only = record_with_env_effort("low"); +fn clearing_record_native_env_reveals_canonical_and_creates_a_diff() { + // Record-native env `low` shadows canonical `high`: removing the record env + // key drops resolution to the canonical `high`, a real change that badges. + let mut env_over_canonical = record_with_env_effort("low"); + env_over_canonical.effort_level = Some("high".into()); + let mut canonical_only = goose_record(); + canonical_only.effort_level = Some("high".into()); assert_ne!( - snap(&canonical), - snap(&env_only), - "clearing canonical must reveal the env fallback and badge" + snap(&env_over_canonical), + snap(&canonical_only), + "removing the record-native env must reveal the canonical and badge" ); } -// ── B5 effort: single canonical representation ─────────────────────────── +// ── Effort: single canonical representation ────────────────────────────── // // `effective_effort` and the snapshot's `effort_level` field are the sole -// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the -// snapshot `env` so an authority handoff at an unchanged effective value -// (canonical replacing a user-env seed, or the reverse) raises no spurious -// restart badge, while a genuine effort change surfaces exactly once. +// carrier of startup effort. Every effort key is stripped from the snapshot +// `env` so an authority handoff at an unchanged effective value raises no +// spurious restart badge, while a genuine effort change surfaces exactly once. -/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any. +/// Look up the `env.GOOSE_THINKING_EFFORT` leaf of a canonical snapshot, if any +/// (the record()'s runtime is Goose, so this is its destination key). fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> { canonical .get("env") - .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")) + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) } -/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical -/// authority: no persisted `effort_level`, effort comes from user env_vars). +/// A Goose record whose record-native env seeds `GOOSE_THINKING_EFFORT` (the +/// top authority tier for Goose: effort comes from user env_vars, no column). +/// Pins `runtime = "goose"` so the effective command resolves to Goose and +/// `GOOSE_THINKING_EFFORT` is the record-*native* key — without it the record +/// falls back to the default `buzz-agent` runtime, for which that key is a +/// foreign env alias the projection suppresses rather than an authority tier. fn record_with_env_effort(value: &str) -> ManagedAgentRecord { let mut rec = record(); + rec.runtime = Some("goose".into()); rec.env_vars - .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); + .insert("GOOSE_THINKING_EFFORT".into(), value.into()); rec } -#[test] -fn effective_effort_prefers_persisted_canonical_over_user_env() { - // Canonical wins, mirroring spawn's `apply_effort_env` (written after the - // user env layer). The env value is ignored when a canonical is present. +/// A Goose record with no effort env: the canonical column is the authority. +fn goose_record() -> ManagedAgentRecord { let mut rec = record(); - rec.effort_level = Some("high".into()); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high")); + rec.runtime = Some("goose".into()); + rec +} + +#[test] +fn effective_effort_reads_the_projected_key_for_the_runtime() { + // The projection reduced the descriptor env to one effort key under the + // runtime's destination key. `effective_effort` reads exactly that key. + // A Goose descriptor carries `GOOSE_THINKING_EFFORT`. + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([("GOOSE_THINKING_EFFORT".to_string(), "high".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("high")); } #[test] -fn effective_effort_falls_back_to_user_env_when_no_canonical() { - // No persisted canonical → the user-seeded env value is the effective - // startup effort, exactly what a spawn would leave in place. - let rec = record(); - let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); - assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low")); +fn effective_effort_reads_acp_sentinel_for_keyless_runtime() { + // Claude/Codex/keyless-ACP descriptors carry the effective value under the + // ACP-startup sentinel, which is the destination key for a runtime with no + // native thinking-effort env var (here: the claude adapter command). + let descriptor = EffectiveHarnessDescriptor { + command: "claude-code-acp".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + assert_eq!(effective_effort(&descriptor).as_deref(), Some("low")); } #[test] -fn effective_effort_is_none_without_canonical_or_env() { - assert_eq!(effective_effort(&record(), &BTreeMap::new()), None); +fn effective_effort_is_none_without_a_projected_key() { + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + assert_eq!(effective_effort(&descriptor), None); } #[test] fn snapshot_carries_effort_in_field_not_env() { - // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as + // Always-canonicalize: a record-native effort reaches the snapshot ONLY as // the `effort_level` field; the raw env key is stripped so effort has one // representation, never two. let canonical = snap(&record_with_env_effort("low")); @@ -118,50 +144,63 @@ fn snapshot_carries_effort_in_field_not_env() { assert_eq!( effort_env_leaf(&canonical), None, - "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env" + "GOOSE_THINKING_EFFORT must be stripped from the snapshot env" ); } #[test] -fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { - // User env `low` (no canonical) → persisted canonical `low` while the env - // seed remains: the effective effort is `low` either way, so a restart - // would change nothing. Old raw-env snapshots would have shown drift; the - // single canonical representation makes the projections identical. - let env_authority = record_with_env_effort("low"); - let mut canonical_authority = record_with_env_effort("low"); - canonical_authority.effort_level = Some("low".into()); +fn foreign_transport_sentinel_is_suppressed_for_goose() { + // A user-seeded `BUZZ_ACP_EFFORT_LEVEL` is a foreign transport key for a + // Goose descriptor: never an authority tier, and stripped from the snapshot + // env by the suppress set. Editing it low→medium changes nothing. + let mut low = record(); + low.env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()); + let mut medium = record(); + medium + .env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), "medium".into()); assert_eq!( - snap(&env_authority), - snap(&canonical_authority), - "an authority handoff at the same effort value must not badge" + snap(&low), + snap(&medium), + "a foreign transport effort key must be suppressed for Goose and never badge" + ); + let canonical = snap(&low); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")), + None, + "the foreign sentinel must be stripped from the snapshot env" ); } #[test] -fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() { - // The reverse direction: canonical `low` (env seed present) → env `low` - // only (canonical cleared). Effective effort stays `low`; no badge. +fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { + // Record-native env `low` (no column) → canonical column `low` while the + // record env remains: the effective effort is `low` either way (env wins, + // but the value is identical), so a restart would change nothing. + let env_authority = record_with_env_effort("low"); let mut canonical_authority = record_with_env_effort("low"); canonical_authority.effort_level = Some("low".into()); - let env_authority = record_with_env_effort("low"); assert_eq!( - snap(&canonical_authority), snap(&env_authority), - "clearing the canonical while the env seed holds the same value must not badge" + snap(&canonical_authority), + "an authority handoff at the same effort value must not badge" ); } #[test] fn env_only_effort_edit_changes_effort_level_not_env() { - // An env-only effort edit (no canonical) moves the single `effort_level` - // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL` - // leaf, so the diff names `effort_level` once rather than duplicating it. + // A record-native env effort edit (no column) moves the single + // `effort_level` representation and never reintroduces a + // `env.GOOSE_THINKING_EFFORT` leaf, so the diff names `effort_level` once + // rather than duplicating it. let low = snap(&record_with_env_effort("low")); let high = snap(&record_with_env_effort("high")); assert_ne!( low, high, - "an env-only effort edit must change the snapshot" + "a record-native effort edit must change the snapshot" ); assert_eq!( low.get("effort_level").and_then(|v| v.as_str()), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7d4b43f01d8..968eb36a6c0 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -452,8 +452,14 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, - /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn - /// so the harness applies it via `session/set_config_option` at session creation. + /// Canonical, harness-agnostic startup effort level. This is the single + /// persisted effort authority: at spawn the launch projection + /// (`config_bridge::effort`) resolves the effective value over this column + /// and all env tiers, then emits it under the destination runtime's native + /// key — `GOOSE_THINKING_EFFORT` for Goose, `BUZZ_AGENT_THINKING_EFFORT` for + /// buzz-agent, or the `BUZZ_ACP_EFFORT_LEVEL` startup sentinel for + /// Claude/Codex and keyless/unknown adapters. Preserved across runtime + /// switches (invalid values skip-as-absent at projection time). #[serde(default, skip_serializing_if = "Option::is_none")] pub effort_level: Option, } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..f0c5d99f265 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -220,8 +220,9 @@ with a TypeScript lookup table or an id comparison in a component. dialog (see rule 11): keep effort state inside the section component, never as dialog-level props. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which - already shows both facts — `field.value` (canonical, the effort the next - spawn will launch with) and, when a running ACP session differs, + already shows both facts — `field.value` (canonical: the effort the next + spawn will launch with, projected to the runtime's native key) and, when a + running ACP session differs, `field.overriddenValue` struck through (the live session's current effort). No component owns "configured vs current" logic; the reader's canonical tier ordering feeds both facts. Do not add a second effort write path or restate diff --git a/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs index 81862d7c41b..a6d4330b094 100644 --- a/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs +++ b/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs @@ -282,3 +282,53 @@ test("editValidity_allowlistWithEmptyList_blocksSave", () => { "allowlist with at least one pubkey must allow Save", ); }); + +// ── Cancel-safety: inherit toggle → Cancel emits no update_managed_agent ────── +// +// Acceptance pin (plan item 1). The pin→inherit effort/runtime clear is derived +// entirely inside the backend's locked save, keyed off the agentCommand:"" +// sentinel `resolveAgentCommandUpdate` produces at SUBMIT. Cancel-safety is +// therefore a UI-wiring invariant: flipping the inherit toggle mutates only +// local dialog state, and the Cancel button routes to onOpenChange, never to +// the submit path — so no update_managed_agent call (and thus no column/env +// clear) is ever dispatched when the user backs out. +// +// This mirrors the AgentInstanceEditDialog footer exactly: Cancel → +// onOpenChange(false); Save → handleSubmit → updateMutation.mutateAsync(input), +// where `input.agentCommand` is the resolveAgentCommandUpdate sentinel. +test("inheritToggle_cancelled_emitsNoUpdate", () => { + const calls = []; + // The ONLY producer of the persistence-boundary sentinel is the submit path. + function handleSubmit() { + const agentCommandUpdate = resolveAgentCommandUpdate({ + inheritHarness: true, // user just toggled inherit ON + agentCommand: pinnedAgent.agentCommand, + originalAgentCommand: pinnedAgent.agentCommand, + agentCommandOverride: pinnedAgent.agentCommandOverride ?? null, + }); + calls.push({ agentCommand: agentCommandUpdate }); + } + function onOpenChange() { + /* dialog close — no mutation */ + } + + // User toggles inherit (local state only), then clicks Cancel. + const cancelButton = { onClick: () => onOpenChange(false) }; + cancelButton.onClick(); + + assert.equal( + calls.length, + 0, + "Cancel after toggling inherit must not dispatch update_managed_agent", + ); + + // Sanity: the submit path WOULD have emitted the inherit sentinel, proving + // the clear is gated on Save alone — Cancel simply never reaches it. + handleSubmit(); + assert.equal(calls.length, 1); + assert.equal( + calls[0].agentCommand, + "", + "Save on the pin→inherit transition emits the empty-command sentinel the backend clears the column on", + ); +}); diff --git a/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs b/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs index 470b2312bc7..1b1f0a6a7d9 100644 --- a/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs +++ b/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs @@ -17,6 +17,43 @@ const base = { // --- selectionOnRuntimeChange --- +test("runtime switch clears stale effort env aliases (native + ACP sentinel), preserving the direct-write column", () => { + // Claude/buzz-agent → Goose: the previous runtime's effort env aliases are + // stale under Goose. They are cleared; unrelated env survives. The canonical + // effort column is direct-write, not in this state, so it is untouched here. + const next = selectionOnRuntimeChange( + { + ...base, + envVars: { + BUZZ_ACP_EFFORT_LEVEL: "high", + BUZZ_AGENT_THINKING_EFFORT: "medium", + GOOSE_THINKING_EFFORT: "max", + KEEP: "x", + }, + }, + { + previousRuntime: "buzz-agent", + nextRuntime: "goose", + nextRuntimeCanChooseProvider: true, + lockedRuntimeReset: "full", + }, + ); + assert.deepEqual(next.envVars, { KEEP: "x" }); +}); + +test("no-op runtime change (previous === next) leaves effort env aliases intact", () => { + const next = selectionOnRuntimeChange( + { ...base, envVars: { GOOSE_THINKING_EFFORT: "high", KEEP: "x" } }, + { + previousRuntime: "goose", + nextRuntime: "goose", + nextRuntimeCanChooseProvider: true, + lockedRuntimeReset: "full", + }, + ); + assert.deepEqual(next.envVars, { GOOSE_THINKING_EFFORT: "high", KEEP: "x" }); +}); + test("runtime change to a provider-locked runtime, full reset (Persona/Edit): clears provider, custom flags, and managed API key", () => { const next = selectionOnRuntimeChange( { diff --git a/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts b/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts index e98dc540dff..33d921d00e3 100644 --- a/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts +++ b/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts @@ -20,6 +20,24 @@ import { * dialog-specific side effects (inherit pins, command sync, catalog memory) * at the call site. Divergent behaviors are parameterized, never merged. */ + +/** + * Every runtime-owned thinking-effort env key: the native keys of all known + * runtimes plus the retained ACP-startup transport sentinel. Mirrors the Rust + * `effort_suppress_keys()` full sweep (`config_bridge/effort.rs`). + * + * On a runtime switch these aliases become stale — they express the *previous* + * runtime's vocabulary — so they are cleared. The canonical persisted effort + * (`record.effort_level`) is a direct-write column owned by `EffortPickerField` + * (AGENTS.md rule 14), lives outside this env-state selection, and is therefore + * PRESERVED across the switch: the launch projection normalizes it (or skips it + * as absent) for the destination runtime, and switching back restores it. + */ +const EFFORT_ENV_ALIASES = [ + "GOOSE_THINKING_EFFORT", + "BUZZ_AGENT_THINKING_EFFORT", + "BUZZ_ACP_EFFORT_LEVEL", +] as const; export type RuntimeModelProviderSelection = { provider: string; model: string; @@ -45,6 +63,19 @@ export function selectionOnRuntimeChange( ): RuntimeModelProviderSelection { const next = { ...current }; + // F3 nondestructive switch policy: clear the previous runtime's stale + // thinking-effort env aliases (all native keys + the ACP sentinel). The + // canonical `record.effort_level` column is direct-write and not part of this + // selection state, so it is preserved — the launch projection re-expresses it + // for the destination runtime, and switching back restores the preference. + if (params.previousRuntime !== params.nextRuntime) { + let envVars = next.envVars; + for (const key of EFFORT_ENV_ALIASES) { + envVars = envVarsWithoutKey(envVars, key); + } + next.envVars = envVars; + } + if ( shouldClearModelForRuntimeChange( params.previousRuntime, diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 9f77566da99..9c6a6b29c80 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -64,9 +64,12 @@ export async function setManagedAgentAutoRestart( } /** - * B5: persist the canonical startup effort for a local managed agent. Applied - * as `BUZZ_ACP_EFFORT_LEVEL` at the next spawn. Pass `null` to clear (reverts - * to the adapter default). Rejects non-local agents. + * Persist the canonical startup effort for a local managed agent. Stored as the + * harness-agnostic `effort_level` column and projected to each runtime's native + * key at the next spawn (`GOOSE_THINKING_EFFORT` for Goose, + * `BUZZ_AGENT_THINKING_EFFORT` for buzz-agent, the `BUZZ_ACP_EFFORT_LEVEL` + * startup sentinel for Claude/Codex and keyless adapters). Pass `null` to clear + * (reverts to the inherited/adapter default). Rejects non-local agents. */ export async function persistAgentEffortLevel( pubkey: string, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d2251c25a56..0a2e91a2700 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -667,9 +667,9 @@ export type RuntimeConfigSurface = { sources: ConfigSourceReport; /** #3493: `true` when the surface was read from a user-set `CLAUDE_CONFIG_DIR` — drives the Keychain caveat note in the panel. */ claudeConfigDirCustom?: boolean; - /** B5: the adapter-advertised `thought_level` configId, discovered from the running session. Present only for claude after the first session. Drives the effort picker. */ + /** The adapter-advertised `thought_level` configId, discovered from the running session — present once a session advertises `thought_level` support (Claude today; any effort-capable ACP adapter in general). Drives the effort picker. */ effortConfigId?: string; - /** B5/I-7: adapter-advertised option values for the `thought_level` option — the picker renders these instead of hardcoded values. */ + /** Adapter-advertised option values for the `thought_level` option — the picker renders these instead of hardcoded values. */ effortOptions?: AcpConfigOptionValue[]; }; From b2797d6b9e22c3d20fcc211cf809569b88cadd4f Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 19 Aug 2026 18:04:20 -0400 Subject: [PATCH 02/33] fix(desktop): restore effort reader compatibility pins Restore the reader behaviors omitted from the rebuilt effort projection: consumed legacy keys stay out of Advanced fields, and transition adapters retain their effort category fallback. Replace the simulated Cancel assertion with a rendered dialog seam test so an accidental submit wiring change is observable. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Duncan --- .../managed_agents/config_bridge/reader.rs | 44 +- .../config_bridge/reader_tests_ext.rs | 155 +++++++ .../ui/agentInstanceEditCancelSafety.test.mjs | 432 ++++++++++++++++++ 3 files changed, 626 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index c6d59628e8f..52ecae5164e 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -131,7 +131,7 @@ pub(crate) fn read_config_surface( .collect(); // Collect the env var keys already covered by normalized fields. - let normalized_env_keys: Vec<&str> = [ + let mut normalized_env_keys: Vec<&str> = [ model_env_var, provider_env_var, thinking_env_var, @@ -143,6 +143,31 @@ pub(crate) fn read_config_surface( .flatten() .collect(); + // Hide the legacy effort key from advanced only when the record tier + // actually consumed it as effort — i.e. the native record key is + // absent/invalid and the legacy value normalizes. Otherwise + // `build_thinking_field` surfaces the consumed value AND the advanced loop + // re-emits the same key as a generic env var (double-emit). Invalid or + // unconsumed legacy values stay visible in advanced. + let record_legacy_consumed = thinking_env_var + .zip(effort_norm) + .is_some_and(|(native, norm)| { + native != LEGACY_THINKING_EFFORT_KEY + && record + .env_vars + .get(native) + .and_then(|v| norm.normalize_str(v)) + .is_none() + && record + .env_vars + .get(LEGACY_THINKING_EFFORT_KEY) + .and_then(|v| norm.normalize_str(v)) + .is_some() + }); + if record_legacy_consumed { + normalized_env_keys.push(LEGACY_THINKING_EFFORT_KEY); + } + // Tier 2a: remaining env vars not covered by normalized fields. let mut advanced = advanced; for (k, v) in &record.env_vars { @@ -796,11 +821,20 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio /// config id (Claude Code uses `id="effort"`). Selecting by category — not by /// a hardcoded id — is what lets the running value, the write config id, and /// the picker options all derive from one entry. +/// +/// `thought_level` is preferred; the legacy invented category `effort` is a +/// fallback for old test fixtures and pre-canonical adapters. The fallback +/// fires only when `thought_level` is entirely absent — an advertised-but-unset +/// `thought_level` entry is still returned (its `current_value` is `None`), so +/// the reader never flips write-routing to the legacy `effort` config id. fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> { - cache - .config_options - .iter() - .find(|o| o.category.as_deref() == Some("thought_level")) + let by_category = |category: &str| { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some(category)) + }; + by_category("thought_level").or_else(|| by_category("effort")) } fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 1e14ac5ad0f..31db8b465e8 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -792,3 +792,158 @@ fn goose_invalid_acp_skips_and_record_wins() { Some("high") ); } + +// ── Consumed-legacy Advanced suppression (F2) ──────────────────────────────── +// +// When the record's native effort key is absent/invalid and the legacy key +// (`BUZZ_AGENT_THINKING_EFFORT`) supplies the normalized record effort, the +// legacy key must NOT also re-appear as a generic Advanced field — one +// persisted fact must not surface through two controls. Invalid/unconsumed +// legacy values stay visible in Advanced. + +/// Record has valid legacy `BUZZ_AGENT_THINKING_EFFORT=high` and no native +/// `GOOSE_THINKING_EFFORT` → effort surfaces from the legacy alias AND the +/// legacy key must NOT re-appear in Advanced. +#[test] +fn record_consumed_legacy_effort_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("valid legacy value must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "consumed legacy effort key must not double-emit in advanced; got {advanced_keys:?}" + ); +} + +/// Record has invalid legacy `BUZZ_AGENT_THINKING_EFFORT=bogus` (not a valid +/// Goose value) → not consumed as effort, so it must stay VISIBLE in Advanced. +#[test] +fn record_invalid_legacy_effort_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "bogus".to_string(), + ); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "invalid legacy value must not be consumed as effort" + ); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "unconsumed legacy key must stay visible in advanced; got {advanced_keys:?}" + ); +} + +// ── F4: legacy `effort` category fallback in find_effort_option ────────────── +// +// `thought_level` is preferred; the legacy invented category `effort` is a +// fallback for pre-canonical adapters. An advertised-but-unset `thought_level` +// must NOT fall through to a set `effort` (that would route the write to the +// wrong config_id), but a cache that advertises only `effort` must still +// surface a thinking field and write route. + +/// `thought_level` present but unset, `effort` present and set → effort must +/// NOT surface from the live cache (no fallthrough); write routing never picks +/// up the legacy `effort` config id. +#[test] +fn unset_thought_level_does_not_fall_through_to_effort_category() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![ + AcpConfigOptionEntry { + config_id: "thinking_effort".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Thinking Effort".to_string()), + current_value: None, // advertised but unset + options: vec![], + }, + AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }, + ], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + assert!( + surface.normalized.thinking_effort.is_none(), + "unset thought_level must not fall through to the legacy effort category" + ); +} + +/// `effort` category present and set, no `thought_level` at all → legacy +/// fallback still surfaces the field and routes the write to the matched +/// `effort` config id. +#[test] +fn effort_category_fallback_used_when_thought_level_absent() { + let record = test_record(); + let runtime = test_runtime(); // Goose + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort (legacy)".to_string()), + current_value: Some("high".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("legacy effort category must surface when thought_level is absent"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert!( + matches!( + &effort.write_via, + ConfigWriteMechanism::AcpSetConfigOption { config_id } + if config_id == "effort" + ), + "write route must use the legacy effort config_id when it is the only category; got {:?}", + effort.write_via + ); +} diff --git a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs new file mode 100644 index 00000000000..a8338a7ee91 --- /dev/null +++ b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs @@ -0,0 +1,432 @@ +/** + * Cancel-safety acceptance pin (production seam). + * + * The pin→inherit effort/runtime clear is derived entirely inside the backend's + * locked save, keyed off the `agentCommand: ""` sentinel that + * `resolveAgentCommandUpdate` produces at SUBMIT. Cancel-safety is therefore a + * UI-wiring invariant: toggling the inherit checkbox mutates only local dialog + * state, and the real Cancel button must route to `onOpenChange`, never to the + * submit path — so no `update_managed_agent` (and thus no column/env clear) is + * ever dispatched when the user backs out. + * + * Why a full production render rather than a hand-written miniature: the seam + * being pinned is the DIALOG FOOTER's wiring (Cancel → handleOpenChange, Save → + * handleSubmit → update_managed_agent). A miniature that re-implements a fake + * Cancel/Save cannot catch a regression that rewires the real Cancel button to + * handleSubmit. This test mounts the actual `AgentInstanceEditDialog`, expands + * Advanced, toggles the inherit checkbox, clicks the REAL Cancel button, and + * asserts the mocked `update_managed_agent` IPC boundary recorded zero calls — + * so rewiring Cancel to handleSubmit() makes it fail. The companion test clicks + * the REAL Save button and asserts the same boundary receives exactly one call + * carrying the `agentCommand: ""` inherit sentinel. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Track every QueryClient so afterEach can cancel pending queries + clear the +// cache — react-query's default gcTime otherwise schedules timers that outlive +// the test and stall the shared `pnpm test` process. +const clients = []; + +let act; +let cleanup; +let fireEvent; +let render; +let screen; +let createElement; +let QueryClient; +let QueryClientProvider; +let ThemeProvider; +let AgentInstanceEditDialog; + +// Records every Tauri command invocation the mounted dialog issues; unmocked +// commands reject so a new IPC dependency surfaces as a loud failure. +const ipcCalls = []; +const ipcHandlers = new Map(); + +const AGENT_PK = "d".repeat(64); + +// A goose-pinned instance linked to a claude persona — the pin→inherit +// transition. `agentCommandOverride` non-null means it opens PINNED (inherit +// checkbox unchecked); toggling inherit ON produces the `agentCommand: ""` +// clear sentinel at submit. Claude persona keeps the prospective runtime +// credential-free so the Save sanity case is enabled. +function rawAgent(overrides = {}) { + return { + pubkey: AGENT_PK, + name: "pinned-instance", + persona_id: "p1", + runtime: "goose", + relay_url: "wss://relay.example", + acp_command: "acp", + agent_command: "goose", + agent_command_override: "goose", + agent_args: [], + mcp_command: "mcp", + turn_timeout_seconds: 300, + idle_timeout_seconds: null, + max_turn_duration_seconds: null, + parallelism: 1, + system_prompt: null, + avatar_url: null, + model: null, + provider: null, + persona_out_of_date: false, + persona_orphaned: false, + needs_restart: false, + env_vars: {}, + status: "running", + pid: 1234, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + last_error_code: null, + log_path: "/tmp/agent.log", + start_on_app_launch: false, + auto_restart_on_config_change: true, + backend: { type: "local" }, + backend_agent_id: null, + respond_to: "mentions", + respond_to_allowlist: [], + ...overrides, + }; +} + +function rawPersona(overrides = {}) { + return { + id: "p1", + display_name: "Scribe", + avatar_url: null, + system_prompt: "be helpful", + runtime: "claude", + model: null, + provider: null, + name_pool: [], + is_builtin: false, + is_active: true, + shared: false, + source_team: null, + env_vars: {}, + respond_to: null, + respond_to_allowlist: [], + parallelism: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function rawRuntime(id, overrides = {}) { + return { + id, + label: id, + avatar_url: "", + availability: "available", + command: id, + binary_path: `/usr/local/bin/${id}`, + default_args: [], + mcp_command: null, + install_hint: "", + install_instructions_url: "", + can_auto_install: false, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "logged_in" }, + source: "builtin", + ...overrides, + }; +} + +function configSurface() { + return { + runtimeId: "goose", + runtimeLabel: "goose", + isPreSpawn: false, + normalized: { + model: null, + provider: null, + mode: null, + thinkingEffort: null, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + extensions: [], + sources: { + acpNative: "notApplicable", + acpConfigOptions: "notApplicable", + envVars: "available", + configFile: "notApplicable", + configFilePath: null, + mcpConfigFilePath: null, + }, + }; +} + +function installIpc() { + const set = (cmd, handler) => ipcHandlers.set(cmd, handler); + set("discover_acp_providers", () => + Promise.resolve([rawRuntime("claude"), rawRuntime("goose")]), + ); + set("list_personas", () => Promise.resolve([rawPersona()])); + set("get_agent_config_surface", () => Promise.resolve(configSurface())); + set("get_global_agent_config", () => + Promise.resolve({ + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }), + ); + set("get_baked_build_env", () => Promise.resolve([])); + set("get_baked_build_env_keys", () => Promise.resolve([])); + set("get_runtime_file_config", () => Promise.resolve(null)); + set("agent_access_owner_only", () => Promise.resolve(false)); + set("discover_agent_models", () => + Promise.resolve({ + agentName: "goose", + agentVersion: "1.0", + models: [], + agentDefaultModel: null, + selectedModel: null, + supportsSwitching: false, + }), + ); + // The persistence boundary under test. Returns a valid response so the + // mutation's onSuccess/onSettled cache updates don't throw. + set("update_managed_agent", (args) => { + ipcCalls.push({ cmd: "update_managed_agent", args }); + return Promise.resolve({ agent: rawAgent(), profile_sync_error: null }); + }); +} + +function renderDialog(onOpenChange) { + const client = new QueryClient({ + defaultOptions: { + mutations: { gcTime: 0 }, + queries: { gcTime: 0, retry: false }, + }, + }); + clients.push(client); + return render( + createElement( + ThemeProvider, + { defaultTheme: "buzz" }, + createElement( + QueryClientProvider, + { client }, + createElement(AgentInstanceEditDialog, { + agent: { ...toCamelAgent(rawAgent()) }, + open: true, + onOpenChange, + onUpdated: () => {}, + }), + ), + ), + ); +} + +// The dialog takes a camelCase ManagedAgent prop (the caller maps it via +// fromRawManagedAgent). Only the fields the dialog reads are needed. +function toCamelAgent(raw) { + return { + pubkey: raw.pubkey, + name: raw.name, + personaId: raw.persona_id, + runtime: raw.runtime, + relayUrl: raw.relay_url, + acpCommand: raw.acp_command, + agentCommand: raw.agent_command, + agentCommandOverride: raw.agent_command_override, + agentArgs: raw.agent_args, + mcpCommand: raw.mcp_command, + turnTimeoutSeconds: raw.turn_timeout_seconds, + idleTimeoutSeconds: raw.idle_timeout_seconds, + maxTurnDurationSeconds: raw.max_turn_duration_seconds, + parallelism: raw.parallelism, + systemPrompt: raw.system_prompt, + avatarUrl: raw.avatar_url, + model: raw.model, + modelSource: null, + provider: raw.provider, + personaOutOfDate: raw.persona_out_of_date, + personaOrphaned: raw.persona_orphaned, + needsRestart: raw.needs_restart, + restartDiff: [], + envVars: raw.env_vars, + status: raw.status, + pid: raw.pid, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + lastStartedAt: raw.last_started_at, + lastStoppedAt: raw.last_stopped_at, + lastExitCode: raw.last_exit_code, + lastError: raw.last_error, + lastErrorCode: raw.last_error_code, + logPath: raw.log_path, + startOnAppLaunch: raw.start_on_app_launch, + autoRestartOnConfigChange: raw.auto_restart_on_config_change, + backend: raw.backend, + backendAgentId: raw.backend_agent_id, + respondTo: raw.respond_to, + respondToAllowlist: raw.respond_to_allowlist, + }; +} + +async function expandAdvancedAndToggleInherit() { + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Advanced/ })); + }); + const checkbox = dom.window.document.getElementById( + "edit-agent-inherit-harness", + ); + assert.ok( + checkbox, + "inherit checkbox must render for a persona-linked agent inside Advanced", + ); + assert.equal( + checkbox.checked, + false, + "a harness-pinned agent must open with inherit unchecked", + ); + await act(async () => { + fireEvent.click(checkbox); + }); + assert.equal(checkbox.checked, true, "inherit toggle must flip to checked"); +} + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + // Radix + testing-library reach for a broad set of DOM constructors and + // globals off the realm's `globalThis`. Node ships its own incompatible + // `Event`/`CustomEvent` globals, so JSDOM nodes reject events built from + // them ("parameter 1 is not of type 'Event'"). Force every DOM constructor + // and *Event/*Element/Node* binding to JSDOM's, overriding Node's built-ins, + // so the mounted dialog resolves them all against one realm. + for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key === "window" || key === "document" || key === "globalThis") + continue; + const value = dom.window[key]; + if ( + typeof value === "function" && + /^(HTML|SVG)|Element$|Event$|EventTarget$|^Node|^Document|Observer$/.test( + key, + ) + ) { + globalThis[key] = value; + } + } + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + // Radix Dialog probes pointer-capture and scrolls focus into view on mount. + dom.window.HTMLElement.prototype.hasPointerCapture = () => false; + dom.window.HTMLElement.prototype.releasePointerCapture = () => {}; + dom.window.HTMLElement.prototype.scrollIntoView = () => {}; + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + + ({ act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ThemeProvider } = await import("@/shared/theme/ThemeProvider")); + ({ AgentInstanceEditDialog } = await import("./AgentInstanceEditDialog.tsx")); +}); + +afterEach(() => { + cleanup?.(); + for (const client of clients.splice(0)) { + client.cancelQueries(); + client.clear(); + } + ipcHandlers.clear(); + ipcCalls.length = 0; +}); + +after(() => dom.window.close()); + +test("inherit toggle then Cancel dispatches no update_managed_agent", async () => { + installIpc(); + let openChange; + await act(async () => { + renderDialog((next) => { + openChange = next; + }); + }); + + await expandAdvancedAndToggleInherit(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + }); + + assert.equal( + openChange, + false, + "Cancel must route through onOpenChange(false)", + ); + assert.equal( + ipcCalls.filter((c) => c.cmd === "update_managed_agent").length, + 0, + "Cancel after toggling inherit must not dispatch update_managed_agent — rewiring Cancel to handleSubmit() breaks this", + ); +}); + +test("inherit toggle then Save dispatches the agentCommand:'' inherit sentinel", async () => { + installIpc(); + await act(async () => { + renderDialog(() => {}); + }); + + await expandAdvancedAndToggleInherit(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + }); + + const updates = ipcCalls.filter((c) => c.cmd === "update_managed_agent"); + assert.equal(updates.length, 1, "Save must dispatch exactly one update"); + assert.equal( + updates[0].args.input.agentCommand, + "", + "Save on the pin→inherit transition must carry the empty-command sentinel the backend clears the column on", + ); +}); From 46a083c5005a7537489664ca7fb72b6c0246968e Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 19 Aug 2026 18:35:05 -0400 Subject: [PATCH 03/33] fix(desktop): retain column-shadowed legacy effort input A valid legacy effort value remains persisted when the canonical column wins, so retain it in Advanced rather than hiding an unconsumed value. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Signed-off-by: Duncan --- .../managed_agents/config_bridge/reader.rs | 15 +++++---- .../config_bridge/reader_tests_ext.rs | 32 +++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 52ecae5164e..4916b748a2c 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -143,12 +143,10 @@ pub(crate) fn read_config_surface( .flatten() .collect(); - // Hide the legacy effort key from advanced only when the record tier - // actually consumed it as effort — i.e. the native record key is - // absent/invalid and the legacy value normalizes. Otherwise - // `build_thinking_field` surfaces the consumed value AND the advanced loop - // re-emits the same key as a generic env var (double-emit). Invalid or - // unconsumed legacy values stay visible in advanced. + // Hide the legacy effort key from advanced only when it actually wins the + // record tier: native and canonical column are absent/invalid, then legacy + // normalizes. Otherwise `build_thinking_field` represents another winner + // and the legacy key stays editable in Advanced. let record_legacy_consumed = thinking_env_var .zip(effort_norm) .is_some_and(|(native, norm)| { @@ -158,6 +156,11 @@ pub(crate) fn read_config_surface( .get(native) .and_then(|v| norm.normalize_str(v)) .is_none() + && record + .effort_level + .as_deref() + .and_then(|v| norm.normalize_str(v)) + .is_none() && record .env_vars .get(LEGACY_THINKING_EFFORT_KEY) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 31db8b465e8..998dc1d0589 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -829,8 +829,36 @@ fn record_consumed_legacy_effort_hidden_from_advanced_reader() { ); } -/// Record has invalid legacy `BUZZ_AGENT_THINKING_EFFORT=bogus` (not a valid -/// Goose value) → not consumed as effort, so it must stay VISIBLE in Advanced. +/// A valid legacy value shadowed by the canonical column is not consumed, so +/// it remains editable in Advanced rather than silently resurfacing later if +/// the column is cleared. +#[test] +fn record_legacy_effort_shadowed_by_column_stays_visible_in_advanced_reader() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "low".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("canonical column must win over legacy record effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"BUZZ_AGENT_THINKING_EFFORT"), + "valid but unconsumed record legacy must remain visible in Advanced; got {advanced_keys:?}" + ); +} + +/// An invalid legacy `BUZZ_AGENT_THINKING_EFFORT` value is unconsumed, so it +/// stays visible in Advanced. #[test] fn record_invalid_legacy_effort_stays_visible_in_advanced_reader() { let mut record = test_record(); From 175a4733f32803992f9cd1c4500a2d93c4e2a333 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 18:42:38 -0400 Subject: [PATCH 04/33] fix(desktop): close four effort-projection seams from external review The harness-agnostic effort contract held for the architecture as specified, but four reachable seams the spec did not name diverged from either the PR's own write policy or main's behavior: 1. Picker direct-write skipped the alias sweep. persist_agent_effort_level wrote only the effort_level column while the projection ranks record native env (tier 1) above it, so a leftover GOOSE_THINKING_EFFORT in the record silently outranked the just-set value. Strip record effort aliases atomically with the column write, reusing the Save-path sweep. 2. Unknown/custom runtimes over-suppressed. With no runtime metadata the projection still stripped every known effort key, regressing a working custom Goose wrapper from pass-through to silently stripped. Restore main's pass-through: empty suppress set when runtime is None; the canonical column still emits raw under BUZZ_ACP_EFFORT_LEVEL. 3. Foreign canonical values crashed the destination child. A record with effort_level=off (valid Goose) switched to buzz-agent emitted BUZZ_AGENT_THINKING_EFFORT=off, which parse_thinking_effort rejects (child exits 2). Add a validation-only accepted-values contract for buzz-agent and gate every projected/read value through normalize_effort; invalid -> skip as absent, same rule every other tier follows. 4. Exact-case lookups bypassed on Windows. Windows Command case-folds env names, so goose_thinking_effort evaded suppression and could shadow the projected authority. Make effort lookup, suppression, and the record sweep ASCII-case-insensitive on both the Rust and TS sides. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/agent_config.rs | 11 ++ .../src/commands/agent_config_tests.rs | 1 + .../managed_agents/config_bridge/effort.rs | 97 +++++++++-- .../config_bridge/effort_tests.rs | 159 ++++++++++++++++++ .../managed_agents/config_bridge/reader.rs | 23 ++- .../config_bridge/reader_tests.rs | 2 + .../src-tauri/src/managed_agents/discovery.rs | 1 - .../src/managed_agents/discovery/catalog.rs | 8 +- .../src/managed_agents/discovery/overrides.rs | 9 +- .../discovery/runtime_metadata.rs | 25 +++ .../src-tauri/src/managed_agents/readiness.rs | 2 + .../agents/ui/providerEnvVarUpdates.ts | 15 ++ .../ui/runtimeModelProviderSelection.ts | 6 +- 13 files changed, 328 insertions(+), 31 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index a49bd47465f..1d42469573d 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -574,6 +574,17 @@ pub fn persist_agent_effort_level( )); } record.effort_level = effort_level; + // The picker is the single record-scope effort authority. Strip every + // record-level effort env alias (all native keys + legacy + the sentinel) + // atomically with the column write: the launch projection ranks record + // native env (tier 1) ABOVE the canonical column (tier 2), so a leftover + // `GOOSE_THINKING_EFFORT` in `record.env_vars` would silently outrank the + // value the picker just set — the panel promises one thing, the spawn does + // another. This is the same sweep the Save-path pin→inherit transition + // applies (`remove_record_effort_aliases`); the picker's direct-write path + // must not skip it. Applies on clear too, so reverting to inherit drops any + // stale record-scope alias rather than resurrecting it. + crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars); record.updated_at = crate::util::now_iso(); save_managed_agents(&app, &records) } diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 6a8013d9770..41df67d85d8 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -56,6 +56,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), effort_normalization: Some(&crate::managed_agents::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index 7e7472cae63..4c80fcecd16 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -34,7 +34,7 @@ use std::collections::BTreeMap; use super::LEGACY_THINKING_EFFORT_KEY; use crate::managed_agents::custom_harnesses::HarnessDefinition; -use crate::managed_agents::discovery::KnownAcpRuntime; +use crate::managed_agents::discovery::{EffortNormalization, KnownAcpRuntime}; use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; /// The retained ACP-startup transport key. Claude, Codex, keyless ACP adapters, @@ -64,16 +64,35 @@ impl EffortLaunch { /// Apply the projection to a launch env map: strip every `suppress` key, /// then emit `key = value` when a value is present. After this call the map /// holds at most one effort key (`key`), carrying the effective value. + /// + /// Suppression is ASCII-case-insensitive: Windows `Command` case-folds env + /// names, so a hand-set `goose_thinking_effort` would otherwise evade an + /// exact-case strip and shadow the projected authority. pub(crate) fn apply(&self, env: &mut BTreeMap) { - for k in &self.suppress { - env.remove(*k); - } + env.retain(|k, _| { + !self + .suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); if let Some(ref v) = self.value { env.insert(self.key.to_string(), v.clone()); } } } +/// Look up `key` in `map` case-insensitively (ASCII). Prefers an exact match, +/// then falls back to the first case-insensitive match. Effort key resolution +/// must match Windows `Command` env semantics, where a mixed-case native key +/// is the same variable as its canonical form. +fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { + map.get(key).or_else(|| { + map.iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v) + }) +} + /// Resolve the single harness-agnostic effort authority and apply it to a fully /// layered launch `env`: strip every known/legacy/transport effort key, then /// emit exactly the one destination key holding the effective value. Called by @@ -117,13 +136,13 @@ pub(crate) fn effort_tier_alias( norm: impl Fn(&str) -> Option, allow_legacy_alias: bool, ) -> Option { - if let Some(raw) = map.get(native_key) { + if let Some(raw) = get_ci(map, native_key) { if let Some(canonical) = norm(raw) { return Some(canonical); } } if allow_legacy_alias && native_key != LEGACY_THINKING_EFFORT_KEY { - if let Some(raw) = map.get(LEGACY_THINKING_EFFORT_KEY) { + if let Some(raw) = get_ci(map, LEGACY_THINKING_EFFORT_KEY) { if let Some(canonical) = norm(raw) { return Some(canonical); } @@ -132,6 +151,36 @@ pub(crate) fn effort_tier_alias( None } +/// Normalize/validate an effort candidate for a runtime's destination +/// vocabulary. The single value gate shared by the launch projection and the +/// reader, so the panel and the next spawn never disagree on a value's validity. +/// +/// - `contract` present (Goose): canonicalize through the alias table; invalid +/// → `None` (skip as absent). +/// - `contract` absent but `accepted` present (buzz-agent): validation-only — +/// accept a value case-insensitively iff the destination parser would +/// (`parse_thinking_effort`), emit it lowercased; a foreign canonical (e.g. +/// Goose `off`) is rejected so it is never emitted as +/// `BUZZ_AGENT_THINKING_EFFORT=off`, which crashes the child at config init. +/// - both absent (Claude/Codex, unknown/custom): raw passthrough — the value +/// rides `BUZZ_ACP_EFFORT_LEVEL` to an adapter that accepts any string. +pub(crate) fn normalize_effort( + contract: Option<&EffortNormalization>, + accepted: Option<&[&str]>, + raw: &str, +) -> Option { + match contract { + Some(c) => c.normalize_str(raw), + None => match accepted { + Some(values) => { + let lower = raw.trim().to_ascii_lowercase(); + values.iter().any(|v| *v == lower).then_some(lower) + } + None => Some(raw.to_string()), + }, + } +} + /// The destination env key the effective effort is emitted under for `runtime`: /// the runtime's native `thinking_env_var`, else the ACP-startup sentinel /// (Claude, Codex, keyless ACP adapters, and unknown/custom runtimes). @@ -172,18 +221,30 @@ pub(crate) fn effort_launch_projection( baked_env: &BTreeMap, ) -> EffortLaunch { let key = effort_dest_key(runtime); - let suppress = effort_suppress_keys(); - // Normalizer: contract runtimes canonicalize (invalid → skip); contract-less - // runtimes pass raw (any present value is valid for their per-model catalog). - let contract = runtime.and_then(|r| r.effort_normalization); - let norm = |raw: &str| -> Option { - match contract { - Some(c) => c.normalize_str(raw), - None => Some(raw.to_string()), - } + // Fix (external review #2): unknown/custom runtimes restore main's + // pass-through. With no runtime metadata there is no vocabulary to bridge + // and no known env-tier authority, so suppressing every effort key would + // silently strip a working custom-wrapper key (e.g. `GOOSE_THINKING_EFFORT` + // on a hand-rolled Goose adapter). An empty suppress set leaves user and + // definition effort env untouched; the raw canonical column still emits + // under `BUZZ_ACP_EFFORT_LEVEL` (the retained compatibility path), matching + // main. KNOWN runtimes — including Claude/Codex with `thinking_env_var: + // None` — keep the full sweep + single-key emission. + let suppress = if runtime.is_some() { + effort_suppress_keys() + } else { + Vec::new() }; + // Value gate: Goose canonicalizes through its alias contract; buzz-agent + // validates against its accepted set (invalid → skip, so a foreign + // canonical like Goose `off` is never emitted where the destination parser + // rejects it); Claude/Codex and unknown/custom pass raw over the sentinel. + let contract = runtime.and_then(|r| r.effort_normalization); + let accepted = runtime.and_then(|r| r.effort_accepted_values); + let norm = |raw: &str| -> Option { normalize_effort(contract, accepted, raw) }; + // Tier-reading native key: the runtime's REAL native key. `None` (Claude, // Codex, unknown/custom) means there are no env-tier authorities — the // sentinel in user env is transport only — so the column is the sole source. @@ -227,7 +288,7 @@ fn resolve_effective_effort( // 1. record native — only for runtimes with a real native key. if let Some(nk) = native_key { - if let Some(raw) = record_env.get(nk) { + if let Some(raw) = get_ci(&record_env, nk) { if let Some(v) = norm(raw) { return Some(v); } @@ -242,7 +303,7 @@ fn resolve_effective_effort( // 3. record legacy alias — only when the native key differs from it. if let Some(nk) = native_key { if nk != LEGACY_THINKING_EFFORT_KEY { - if let Some(raw) = record_env.get(LEGACY_THINKING_EFFORT_KEY) { + if let Some(raw) = get_ci(&record_env, LEGACY_THINKING_EFFORT_KEY) { if let Some(v) = norm(raw) { return Some(v); } @@ -275,7 +336,7 @@ fn resolve_effective_effort( } } // 7. baked build floor (native only). - if let Some(raw) = baked_env.get(nk) { + if let Some(raw) = get_ci(baked_env, nk) { if let Some(v) = norm(raw) { return Some(v); } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index e661a3934c2..2055a542515 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -469,3 +469,162 @@ fn buzz_agent_generic_column_does_not_leak_acp_sentinel() { Some("medium") ); } + +// -------------------------------------------------------------------------- +// External review fix #2 — unknown/custom runtimes restore main's pass-through +// -------------------------------------------------------------------------- + +#[test] +fn unknown_runtime_does_not_suppress_user_effort_env() { + // Regression: a custom wrapper with GOOSE_THINKING_EFFORT=high in record env + // must receive it unchanged. On main an unset column left env untouched; + // the projection must not strip effort keys for a runtime it has no + // metadata for (empty suppress set = pass-through). + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "high"), ("UNRELATED", "keep")]); + let launch = project_record_only(&r, None); + assert!( + launch.suppress.is_empty(), + "unknown runtime suppresses nothing" + ); + + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(GOOSE_KEY).map(String::as_str), + Some("high"), + "custom-wrapper effort key survives an unknown-runtime launch" + ); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} + +#[test] +fn unknown_runtime_keeps_user_acp_sentinel_when_no_column() { + // A custom adapter with a hand-set BUZZ_ACP_EFFORT_LEVEL and no canonical + // column keeps its sentinel — nothing to project, nothing suppressed. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "low")]); + let launch = project_record_only(&r, None); + // No column → no projected value → nothing emitted, nothing stripped. + assert_eq!(launch.value, None); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "hand-set sentinel survives on an unknown runtime with no column" + ); +} + +#[test] +fn unknown_runtime_column_still_emits_under_acp_sentinel() { + // The retained compatibility emission: an unknown runtime with a canonical + // column emits it raw under the ACP sentinel (matches the PR-body decision). + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); +} + +// -------------------------------------------------------------------------- +// External review fix #3 — destination-vocabulary validation at projection +// -------------------------------------------------------------------------- + +#[test] +fn goose_off_column_skips_for_buzz_agent_destination() { + // Regression: canonical column `off` is valid Goose but NOT a buzz-agent + // effort. Switching a record with effort_level=off to buzz-agent must NOT + // emit BUZZ_AGENT_THINKING_EFFORT=off — parse_thinking_effort rejects it and + // the child exits 2. Invalid → skip as absent → no key emitted. + let mut r = record(); + r.effort_level = Some("off".into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value, None, + "foreign canonical `off` skipped for buzz-agent's vocabulary" + ); + + let mut launch_env = BTreeMap::new(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(BUZZ_AGENT_KEY), + None, + "no effort key emitted when the value is outside the destination vocabulary" + ); +} + +#[test] +fn buzz_agent_minimal_column_skips_for_goose_destination() { + // The reverse: `minimal` is a valid buzz-agent effort but invalid Goose, so + // switching to Goose skips it as absent (already covered by normalization, + // pinned here as the symmetric vocabulary case). + let mut r = record(); + r.effort_level = Some("minimal".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!(launch.value, None); +} + +#[test] +fn buzz_agent_accepts_its_own_distinct_efforts() { + // buzz-agent keeps xhigh and max distinct (no Goose-style xhigh→max + // collapse): both are valid and pass through unchanged. + for v in ["xhigh", "max", "none", "minimal"] { + let mut r = record(); + r.effort_level = Some(v.into()); + let launch = project_record_only(&r, Some(buzz_agent())); + assert_eq!( + launch.value.as_deref(), + Some(v), + "buzz-agent accepts `{v}` verbatim (no alias collapse)" + ); + } +} + +// -------------------------------------------------------------------------- +// External review fix #4 — case-insensitive suppression / lookup +// -------------------------------------------------------------------------- + +#[test] +fn mixed_case_native_key_is_read_and_wins() { + // Windows Command case-folds env names, so `goose_thinking_effort` is the + // same variable as the canonical form. The tier reader must find it. + let mut r = record(); + r.env_vars = env(&[("goose_thinking_effort", "low")]); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("low"), + "mixed-case record-native key is read and outranks the column" + ); +} + +#[test] +fn apply_strips_mixed_case_effort_keys() { + // A hand-set mixed-case foreign effort key must be swept, not left to + // shadow the projected value once Windows case-folds it at spawn. + let mut r = record(); + r.effort_level = Some("high".into()); + let launch = project_record_only(&r, Some(goose())); + + let mut launch_env = env(&[ + ("Goose_Thinking_Effort", "stale"), + ("buzz_acp_effort_level", "stale"), + ("UNRELATED", "keep"), + ]); + launch.apply(&mut launch_env); + + // Only the canonical projected key remains; both mixed-case foreign keys + // are gone. + assert_eq!(launch_env.get(GOOSE_KEY).map(String::as_str), Some("high")); + assert_eq!(launch_env.get("Goose_Thinking_Effort"), None); + assert_eq!(launch_env.get("buzz_acp_effort_level"), None); + assert_eq!( + launch_env.get("UNRELATED").map(String::as_str), + Some("keep") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 4916b748a2c..acb6f4706ef 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -44,6 +44,7 @@ pub(crate) fn read_config_surface( let provider_locked = runtime_meta.is_some_and(|m| m.provider_locked); let thinking_env_var = runtime_meta.and_then(|m| m.thinking_env_var); let effort_norm = runtime_meta.and_then(|m| m.effort_normalization); + let effort_accepted = runtime_meta.and_then(|m| m.effort_accepted_values); let supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config); let required_fields: &[&str] = runtime_meta .map(|m| m.required_normalized_fields) @@ -98,6 +99,7 @@ pub(crate) fn read_config_surface( effort_option.map(|o| o.config_id.as_str()), thinking_env_var, effort_norm, + effort_accepted, is_pre_spawn, tiers, ), @@ -576,6 +578,7 @@ fn build_thinking_field( effort_config_id: Option<&str>, thinking_env_var: Option<&str>, effort_norm: Option<&'static EffortNormalization>, + effort_accepted: Option<&'static [&'static str]>, is_pre_spawn: bool, tiers: &InheritedConfigTiers, ) -> Option { @@ -594,10 +597,7 @@ fn build_thinking_field( // (`none`→`off`, `xhigh`→`max`, case-fold) canonicalize. Contract-less // runtimes (buzz-agent, Claude/Codex column) pass raw. let norm = |raw: &str| -> Option { - match effort_norm { - Some(c) => c.normalize_str(raw), - None => Some(raw.to_string()), - } + super::effort::normalize_effort(effort_norm, effort_accepted, raw) }; // Record tiers, split exactly as the projection resolves them: native env @@ -620,9 +620,18 @@ fn build_thinking_field( thinking_env_var.and_then(|k| effort_tier_alias(&tiers.definition_env, k, norm, false)); let file = file_effort.as_deref().and_then(&norm); - // Live ACP value, normalized (invalid → skip as absent). The matched - // `config_id` is preserved for `write_via` regardless of value validity. - let acp_norm = acp_effort.as_deref().and_then(norm); + // Live ACP value: normalized through the runtime CONTRACT only, never the + // persisted `effort_accepted` vocabulary. The ACP running value comes from + // the session's own config-option namespace (e.g. buzz-agent reports + // `default` for its live thinking-level option) — it is a descriptive + // "currently running" fact, never emitted to a spawn, so the + // destination-vocabulary gate that guards the writable tiers must not skip + // it. Goose still canonicalizes (its ACP option values ARE effort values); + // contract-less runtimes pass raw. The matched `config_id` is preserved for + // `write_via` regardless of value validity. + let acp_norm = acp_effort + .as_deref() + .and_then(|v| super::effort::normalize_effort(effort_norm, None, v)); // B same-value collapse: when NO record-level authority exists and the live // ACP value exactly equals what inheritance would already resolve to, drop diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 56c49868526..b972eb787af 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -55,6 +55,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), effort_normalization: Some(&crate::managed_agents::discovery::GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, @@ -649,6 +650,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { supports_acp_native_config: false, thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8db4f27be44..320ce3ff20b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -242,7 +242,6 @@ pub fn effective_agent_command( } mod overrides; -#[cfg(test)] pub use overrides::remove_record_effort_aliases; pub use overrides::{ apply_agent_command_update, apply_env_vars_then_effort_transition, diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs index 1452666cfe1..fecf792f214 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -4,7 +4,9 @@ //! here because this module is declared after `#[macro_use] mod windows_install` //! in the parent. -use super::runtime_metadata::{KnownAcpRuntime, GOOSE_EFFORT_NORMALIZATION}; +use super::runtime_metadata::{ + KnownAcpRuntime, BUZZ_AGENT_EFFORT_VALUES, GOOSE_EFFORT_NORMALIZATION, +}; use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ @@ -37,6 +39,7 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ supports_acp_native_config: true, thinking_env_var: Some("GOOSE_THINKING_EFFORT"), effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, // goose: validated via effort_normalization max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), max_rounds_env_var: None, @@ -71,6 +74,7 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ supports_acp_native_config: false, thinking_env_var: None, effort_normalization: None, // claude: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // claude: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -105,6 +109,7 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ supports_acp_native_config: false, thinking_env_var: None, effort_normalization: None, // codex: canonical routes through BUZZ_ACP_EFFORT_LEVEL (ACP startup) + effort_accepted_values: None, // codex: adapter accepts any value over BUZZ_ACP_EFFORT_LEVEL max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -140,6 +145,7 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ supports_acp_native_config: false, thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), effort_normalization: None, // buzz-agent: per-model catalog; see getProviderEffortConfig() in TS + effort_accepted_values: Some(BUZZ_AGENT_EFFORT_VALUES), // buzz-agent: parse_thinking_effort's accepted set max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 868f5009406..fa339a03b70 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -132,9 +132,12 @@ pub fn apply_agent_command_update( /// in [`apply_agent_command_update`], this makes the instance drop its entire /// per-instance effort override atomically at Save. pub fn remove_record_effort_aliases(env_vars: &mut std::collections::BTreeMap) { - for key in crate::managed_agents::config_bridge::effort::effort_suppress_keys() { - env_vars.remove(key); - } + let suppress = crate::managed_agents::config_bridge::effort::effort_suppress_keys(); + env_vars.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); } /// Apply a same-request `env_vars` replacement and then enforce the pin→inherit diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index af6280a208e..b68bc84c23f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -34,6 +34,19 @@ pub(crate) static GOOSE_EFFORT_NORMALIZATION: EffortNormalization = EffortNormal ], }; +/// buzz-agent's accepted persisted thinking-effort values — a validation-only +/// contract, NOT a canonicalization one. Unlike Goose, buzz-agent keeps `xhigh` +/// and `max` as *distinct* efforts, so these values are validated (invalid → +/// skip as absent) but never aliased or collapsed. +/// +/// Source of truth: `parse_thinking_effort`, `crates/buzz-agent/src/config.rs` +/// (`none|minimal|low|medium|high|xhigh|max`). A destination-vocabulary check +/// at projection time keeps a foreign canonical (e.g. Goose `off`) from being +/// emitted as `BUZZ_AGENT_THINKING_EFFORT=off`, which the parser rejects at +/// config init (child exits 2). +pub(crate) static BUZZ_AGENT_EFFORT_VALUES: &[&str] = + &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + impl EffortNormalization { /// Normalize `raw` to canonical form. `None` → invalid for this harness; /// the caller must treat it as absent (skip-as-absent policy). @@ -117,6 +130,18 @@ pub(crate) struct KnownAcpRuntime { /// projection, and the reader. No value-authority logic may live outside /// this struct for harnesses that declare one. pub effort_normalization: Option<&'static EffortNormalization>, + /// Accepted persisted effort values for a runtime that has NO + /// canonicalization contract but still constrains its vocabulary + /// (buzz-agent: `parse_thinking_effort`'s accepted set). Used only for + /// destination-vocabulary validation at projection/read time — a candidate + /// outside this set is skipped as absent, so a foreign canonical (e.g. + /// Goose `off`) is never emitted under `thinking_env_var` where the + /// destination parser would reject it and crash the child. + /// + /// `None` means "no validation": Goose validates through + /// `effort_normalization`; Claude/Codex and unknown/custom runtimes accept + /// any string over the `BUZZ_ACP_EFFORT_LEVEL` transport. + pub effort_accepted_values: Option<&'static [&'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>, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 0b5e7fd9d47..c20176ad741 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1064,6 +1064,7 @@ mod tests { supports_acp_native_config: false, thinking_env_var: None, effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, @@ -1257,6 +1258,7 @@ mod tests { supports_acp_native_config: false, thinking_env_var: None, effort_normalization: None, + effort_accepted_values: None, max_tokens_env_var: None, context_limit_env_var: None, max_rounds_env_var: None, diff --git a/desktop/src/features/agents/ui/providerEnvVarUpdates.ts b/desktop/src/features/agents/ui/providerEnvVarUpdates.ts index df4c89fadf6..de1a3d0c0f6 100644 --- a/desktop/src/features/agents/ui/providerEnvVarUpdates.ts +++ b/desktop/src/features/agents/ui/providerEnvVarUpdates.ts @@ -21,6 +21,21 @@ export function envVarsWithoutKey( return next; } +/** Remove `envKey` when present, case-insensitively (ASCII). */ +export function envVarsWithoutKeyCaseInsensitive( + current: EnvVarsValue, + envKey: string, +): EnvVarsValue { + const lower = envKey.toLowerCase(); + const match = Object.keys(current).find((k) => k.toLowerCase() === lower); + if (match === undefined) { + return current; + } + const next = { ...current }; + delete next[match]; + return next; +} + /** * Clear the previous provider's managed API key when switching providers. * No-op when the previous provider has no managed key or the next provider diff --git a/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts b/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts index 33d921d00e3..fe13e5d90a1 100644 --- a/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts +++ b/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts @@ -11,6 +11,7 @@ import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; import { envVarsClearingManagedApiKey, envVarsWithoutKey, + envVarsWithoutKeyCaseInsensitive, } from "./providerEnvVarUpdates"; /** @@ -71,7 +72,10 @@ export function selectionOnRuntimeChange( if (params.previousRuntime !== params.nextRuntime) { let envVars = next.envVars; for (const key of EFFORT_ENV_ALIASES) { - envVars = envVarsWithoutKey(envVars, key); + // Case-insensitive: Windows Command case-folds env names, so a hand-set + // `goose_thinking_effort` is the same variable as its canonical form and + // must be swept too. Mirrors the Rust `effort_suppress_keys()` sweep. + envVars = envVarsWithoutKeyCaseInsensitive(envVars, key); } next.envVars = envVars; } From bcf44f409fa4269c526c6fc4551ce6a5fa7f3488 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 19:16:02 -0400 Subject: [PATCH 05/33] fix(agent-config): complete case-insensitive effort contract on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker/reader/transition effort paths resolved record env keys exact-case, so on Windows — where Command case-folds env names — a hand-set goose_thinking_effort could win launch projection while the panel reported a different tier and the UI transition sweep left a case-colliding duplicate live. Resolve reader record-native/legacy tiers and consumed-key detection via the case-insensitive get_ci, and hide normalized keys from Advanced case-insensitively, so the reader and spawned child agree. Delete every case-colliding alias in the TS runtime-switch sweep, not just the first. Also add the direct picker regression: persist_agent_effort_level's column write + alias sweep now runs through a testable helper pinned end-to-end (reader and launch projection both yield the picked value). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/agent_config.rs | 20 ++++-- .../src/commands/agent_config_tests.rs | 52 ++++++++++++++ .../managed_agents/config_bridge/effort.rs | 2 +- .../managed_agents/config_bridge/reader.rs | 22 +++--- .../config_bridge/reader_tests.rs | 3 + .../config_bridge/reader_tests_ext2.rs | 69 +++++++++++++++++++ .../agents/ui/providerEnvVarUpdates.test.mjs | 24 +++++++ .../agents/ui/providerEnvVarUpdates.ts | 10 +-- 8 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 1d42469573d..69b0e777adc 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -573,10 +573,9 @@ pub fn persist_agent_effort_level( "agent {pubkey} is not a local agent; remote effort is set at deploy time" )); } - record.effort_level = effort_level; - // The picker is the single record-scope effort authority. Strip every - // record-level effort env alias (all native keys + legacy + the sentinel) - // atomically with the column write: the launch projection ranks record + // The picker is the single record-scope effort authority. Set the canonical + // column and strip every record-level effort env alias (all native keys + + // legacy + the sentinel) atomically: the launch projection ranks record // native env (tier 1) ABOVE the canonical column (tier 2), so a leftover // `GOOSE_THINKING_EFFORT` in `record.env_vars` would silently outrank the // value the picker just set — the panel promises one thing, the spawn does @@ -584,11 +583,22 @@ pub fn persist_agent_effort_level( // applies (`remove_record_effort_aliases`); the picker's direct-write path // must not skip it. Applies on clear too, so reverting to inherit drops any // stale record-scope alias rather than resurrecting it. - crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars); + apply_picker_effort_level(record, effort_level); record.updated_at = crate::util::now_iso(); save_managed_agents(&app, &records) } +/// Atomically set the record's canonical effort column and strip every stale +/// record-scope effort env alias. Split from the Tauri command so the invariant +/// — no leftover alias can outrank the just-set column — is directly testable. +pub(crate) fn apply_picker_effort_level( + record: &mut ManagedAgentRecord, + effort_level: Option, +) { + record.effort_level = effort_level; + crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars); +} + #[cfg(test)] #[path = "agent_config_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 41df67d85d8..7f5375e959e 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -631,6 +631,58 @@ fn baked_env_mixed_keys_correct_masking() { assert!(token.masked); } +/// F1 picker direct-write invariant: a stale record-native `GOOSE_THINKING_EFFORT` +/// (launch-projection tier 1, ABOVE the canonical column) must not survive a +/// picker write. Setting effort `high` through the picker path both writes the +/// column and sweeps the stale alias, so the reader and the launch projection +/// both resolve `high` — not the stale `low`. Deleting the sweep in +/// `apply_picker_effort_level` re-breaks this: the projection would emit `low`. +#[test] +fn picker_write_sweeps_stale_record_native_effort_alias() { + let mut record = agent_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "low".to_string()); + + super::apply_picker_effort_level(&mut record, Some("high".to_string())); + + // The stale record-native alias is gone; only the column carries the value. + assert!( + !record.env_vars.contains_key("GOOSE_THINKING_EFFORT"), + "stale record-native effort alias must be swept by the picker write" + ); + assert_eq!(record.effort_level.as_deref(), Some("high")); + + // Reader: the panel resolves the just-set value, not the stale alias. + let surface = with_no_goose_config(|| { + resolve_config_surface( + record.clone(), + &[], + Some(goose_runtime()), + None, + &Default::default(), + None, + ) + }); + let effort = surface + .normalized + .thinking_effort + .expect("picker-set effort must resolve"); + assert_eq!(effort.value.as_deref(), Some("high")); + + // Launch projection: the spawned child receives the picker value. + let launch = crate::managed_agents::config_bridge::effort::effort_launch_projection( + &record, + Some(goose_runtime()), + &[], + None, + &std::collections::BTreeMap::new(), + None, + &std::collections::BTreeMap::new(), + ); + assert_eq!(launch.value.as_deref(), Some("high")); +} + #[test] fn baked_env_thinking_effort_is_unmasked() { // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index 4c80fcecd16..76c57c662da 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -85,7 +85,7 @@ impl EffortLaunch { /// then falls back to the first case-insensitive match. Effort key resolution /// must match Windows `Command` env semantics, where a mixed-case native key /// is the same variable as its canonical form. -fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { +pub(super) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { map.get(key).or_else(|| { map.iter() .find(|(k, _)| k.eq_ignore_ascii_case(key)) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index acb6f4706ef..84eec8db33a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -153,9 +153,7 @@ pub(crate) fn read_config_surface( .zip(effort_norm) .is_some_and(|(native, norm)| { native != LEGACY_THINKING_EFFORT_KEY - && record - .env_vars - .get(native) + && super::effort::get_ci(&record.env_vars, native) .and_then(|v| norm.normalize_str(v)) .is_none() && record @@ -163,9 +161,7 @@ pub(crate) fn read_config_surface( .as_deref() .and_then(|v| norm.normalize_str(v)) .is_none() - && record - .env_vars - .get(LEGACY_THINKING_EFFORT_KEY) + && super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY) .and_then(|v| norm.normalize_str(v)) .is_some() }); @@ -173,10 +169,16 @@ pub(crate) fn read_config_surface( normalized_env_keys.push(LEGACY_THINKING_EFFORT_KEY); } - // Tier 2a: remaining env vars not covered by normalized fields. + // Tier 2a: remaining env vars not covered by normalized fields. Matching is + // ASCII-case-insensitive so a mixed-case managed key (e.g. Windows + // `goose_thinking_effort`) the launch projection already consumed is hidden + // from Advanced rather than shown as a spurious editable extra. let mut advanced = advanced; for (k, v) in &record.env_vars { - if normalized_env_keys.contains(&k.as_str()) { + if normalized_env_keys + .iter() + .any(|nk| nk.eq_ignore_ascii_case(k)) + { continue; } if file_config.extra.contains_key(k) { @@ -603,12 +605,12 @@ fn build_thinking_field( // Record tiers, split exactly as the projection resolves them: native env // strictly above the canonical column, legacy env strictly below it. let rec_native = thinking_env_var - .and_then(|k| record.env_vars.get(k)) + .and_then(|k| super::effort::get_ci(&record.env_vars, k)) .and_then(|v| norm(v)); let column = record.effort_level.as_deref().and_then(&norm); let rec_legacy = thinking_env_var .filter(|k| *k != LEGACY_THINKING_EFFORT_KEY) - .and_then(|_| record.env_vars.get(LEGACY_THINKING_EFFORT_KEY)) + .and_then(|_| super::effort::get_ci(&record.env_vars, LEGACY_THINKING_EFFORT_KEY)) .and_then(|v| norm(v)); // Inherited env tiers: persona resolves native-then-legacy; global and diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index b972eb787af..5c56c0102ae 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -962,3 +962,6 @@ fn numeric_max_tokens_inherits_from_global_env() { // ── Extended tests (split file to respect line-count ratchet) ──────────────── #[path = "reader_tests_ext.rs"] mod ext; + +#[path = "reader_tests_ext2.rs"] +mod ext2; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs new file mode 100644 index 00000000000..0c5aa69c407 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext2.rs @@ -0,0 +1,69 @@ +//! Additional tests for `config_bridge/reader.rs` — split out to keep +//! `reader_tests_ext.rs` under the 1000-line file-size ratchet. +//! +//! Included as `mod ext2` inside `reader_tests.rs`, so `use super::*` gives +//! access to all helpers and types from that module. + +use super::*; + +// ── Fix (external review #4): reader resolves record effort keys ───────────── +// case-insensitively, matching the launch projection. +// +// Windows `Command` case-folds env names, so a hand-set `goose_thinking_effort` +// is the same variable as its canonical form. The reader must resolve it as the +// record-native effort winner AND hide it from Advanced, or the panel disagrees +// with the child the launch projection already consumed the key for. + +/// Mixed-case native record key `goose_thinking_effort=high` wins the record +/// tier and is hidden from Advanced (not shown as a spurious editable extra). +#[test] +fn record_mixed_case_native_effort_wins_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("goose_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose (native GOOSE_THINKING_EFFORT) + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case native key must surface as the record effort winner"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"goose_thinking_effort"), + "consumed mixed-case native effort key must not appear in advanced; got {advanced_keys:?}" + ); +} + +/// Mixed-case legacy record key `buzz_agent_thinking_effort=high` (no native, +/// no column) supplies the record effort AND is hidden from Advanced. +#[test] +fn record_mixed_case_legacy_effort_consumed_and_hidden_from_advanced_reader() { + let mut record = test_record(); + record + .env_vars + .insert("buzz_agent_thinking_effort".to_string(), "high".to_string()); + let runtime = test_runtime(); // Goose + + let surface = with_goose_path_root(Some("/nonexistent"), || { + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) + }); + + let effort = surface + .normalized + .thinking_effort + .expect("mixed-case legacy key must surface as effort via record-tier alias"); + assert_eq!(effort.value.as_deref(), Some("high")); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"buzz_agent_thinking_effort"), + "consumed mixed-case legacy effort key must not appear in advanced; got {advanced_keys:?}" + ); +} diff --git a/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs b/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs index 6bce6a7a932..6d540ccfc89 100644 --- a/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs +++ b/desktop/src/features/agents/ui/providerEnvVarUpdates.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { envVarsClearingManagedApiKey, envVarsWithoutKey, + envVarsWithoutKeyCaseInsensitive, } from "./providerEnvVarUpdates.ts"; test("envVarsWithoutKey removes a present key", () => { @@ -47,3 +48,26 @@ test("envVarsClearingManagedApiKey is a no-op when the managed key is shared or noManaged, ); }); + +test("envVarsWithoutKeyCaseInsensitive removes every case-colliding alias in one pass", () => { + // Windows Command case-folds env names, so a persisted state can hold both + // the canonical key and a mixed-case duplicate; a runtime switch must clear + // all of them or a survivor keeps shadowing the projected value on launch. + const next = envVarsWithoutKeyCaseInsensitive( + { + GOOSE_THINKING_EFFORT: "high", + goose_thinking_effort: "low", + KEEP: "x", + }, + "GOOSE_THINKING_EFFORT", + ); + assert.deepEqual(next, { KEEP: "x" }); +}); + +test("envVarsWithoutKeyCaseInsensitive returns the same reference when no alias matches", () => { + const current = { KEEP: "x" }; + assert.equal( + envVarsWithoutKeyCaseInsensitive(current, "GOOSE_THINKING_EFFORT"), + current, + ); +}); diff --git a/desktop/src/features/agents/ui/providerEnvVarUpdates.ts b/desktop/src/features/agents/ui/providerEnvVarUpdates.ts index de1a3d0c0f6..05ff6796006 100644 --- a/desktop/src/features/agents/ui/providerEnvVarUpdates.ts +++ b/desktop/src/features/agents/ui/providerEnvVarUpdates.ts @@ -21,18 +21,20 @@ export function envVarsWithoutKey( return next; } -/** Remove `envKey` when present, case-insensitively (ASCII). */ +/** Remove every case-insensitive (ASCII) match of `envKey` when present. */ export function envVarsWithoutKeyCaseInsensitive( current: EnvVarsValue, envKey: string, ): EnvVarsValue { const lower = envKey.toLowerCase(); - const match = Object.keys(current).find((k) => k.toLowerCase() === lower); - if (match === undefined) { + const matches = Object.keys(current).filter((k) => k.toLowerCase() === lower); + if (matches.length === 0) { return current; } const next = { ...current }; - delete next[match]; + for (const match of matches) { + delete next[match]; + } return next; } From 4c329dcfe09f7ecd2d6d73449f8b576e61f3f93e Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 26 Aug 2026 12:00:13 -0400 Subject: [PATCH 06/33] fix(agent-config): retain custom-runtime effort env in restart snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart snapshot stripped the full effort_suppress_keys() set from its captured launch env unconditionally, but the launch projection uses an EMPTY suppress set for unknown/custom runtimes (external-review-#2 pass-through), so the child actually receives its raw effort env (e.g. GOOSE_THINKING_EFFORT on a hand-rolled wrapper). effective_effort reads only the runtime's dest key — the ACP sentinel for unknown runtimes — which the projection never emitted, so the value landed in neither snapshot.env nor snapshot.effort_level. Net: editing a custom wrapper's effort env produced no restart diff and the running agent stayed on stale effort. Scope the snapshot's env strip to mirror the projection's actual suppression per runtime via snapshot_suppress_keys: known runtimes keep the full strip (a no-op beyond the already-swept dest key); unknown/custom runtimes strip only the sentinel and retain every other effort-looking key as ordinary env so edits diff normally. Stripping is ASCII-case-insensitive to match the projection's apply. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../managed_agents/config_bridge/effort.rs | 21 ++++++ .../src/managed_agents/spawn_snapshot.rs | 40 ++++++++---- .../spawn_snapshot/tests_ext.rs | 65 +++++++++++++++++++ 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index 76c57c662da..1ef9ac4e46e 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -205,6 +205,27 @@ pub(crate) fn effort_suppress_keys() -> Vec<&'static str> { keys } +/// The effort keys the restart snapshot must strip from its captured launch env +/// so effort keeps exactly ONE representation (`effort_level`), mirroring what +/// [`effort_launch_projection`] actually suppressed for `runtime`: +/// +/// - **known runtime** — the full suppress set. The projection already swept +/// every effort key to the single destination key, so this removes only that +/// destination key (a no-op on the already-swept siblings). +/// - **unknown/custom runtime** — only the ACP-startup sentinel. The projection +/// used an EMPTY suppress set here (external-review-#2 pass-through), leaving +/// every other effort-looking key (e.g. a hand-rolled `GOOSE_THINKING_EFFORT`) +/// untouched as ordinary env. Those must remain in `env` so an edit to them +/// diffs the snapshot normally; only the sentinel — the key the projection +/// emits and `effective_effort` reads into `effort_level` — is removed. +pub(crate) fn snapshot_suppress_keys(runtime: Option<&KnownAcpRuntime>) -> Vec<&'static str> { + if runtime.is_some() { + effort_suppress_keys() + } else { + vec![effort_dest_key(runtime)] + } +} + /// Build the single effective-effort projection for a launch. /// /// `global_env`, `persona_id`+`personas`, `harness_def`, and `baked_env` supply diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 55ec314acc6..d5876e83eaf 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -129,11 +129,14 @@ pub(crate) struct SpawnConfigSnapshot { /// The startup effort the harness will actually apply, resolved by /// [`effective_effort`]: the single effort key the harness-agnostic /// projection left in `descriptor.env` under the runtime's destination key. - /// This is the *sole* representation of effort in the snapshot — every - /// effort key is stripped from `env` (see `from_inputs`) so an authority - /// handoff that leaves the effective value unchanged produces no spurious - /// drift entry, and an effort edit surfaces as exactly one `effort_level` - /// entry. + /// This is the *sole* representation of the effective effort in the + /// snapshot: the projection's destination key is stripped from `env` (see + /// `from_inputs`) so an authority handoff that leaves the effective value + /// unchanged produces no spurious drift entry, and an effort edit the + /// projection consumed surfaces as exactly one `effort_level` entry. For an + /// unknown/custom runtime the projection consumes nothing beyond the + /// sentinel, so any other effort-looking key the child receives stays in + /// `env` as ordinary state and diffs normally. pub effort_level: Option, } @@ -177,16 +180,27 @@ impl SpawnConfigSnapshot { .unwrap_or("") .to_string(), // Effort has ONE representation in the snapshot: `effort_level` - // below, always holding the projected effective value. Every effort - // key is stripped from `env` (the full suppress set) so an authority - // handoff at the same value is a no-op (no phantom `env.*EFFORT*` add - // or remove) and an env-only effort edit surfaces as exactly one - // `effort_level` entry rather than a duplicate under `env.`. + // below, always holding the projected effective value. The keys + // stripped here mirror EXACTLY what the launch projection suppressed + // for this runtime (`snapshot_suppress_keys`): a known runtime swept + // every effort key to its single destination key, so the full set is + // stripped (a no-op beyond that dest key); an unknown/custom runtime + // used an empty suppress set (external-review-#2 pass-through), so + // only the ACP-startup sentinel is stripped and every other + // effort-looking key the child actually receives (e.g. a hand-rolled + // `GOOSE_THINKING_EFFORT`) stays as ordinary env — an edit to it must + // diff the snapshot and fire the restart badge. Stripping is + // ASCII-case-insensitive to match the projection's `apply`. env: { let mut env = descriptor.env.clone(); - for key in super::config_bridge::effort::effort_suppress_keys() { - env.remove(key); - } + let suppress = super::config_bridge::effort::snapshot_suppress_keys( + known_acp_runtime(&descriptor.command), + ); + env.retain(|k, _| { + !suppress + .iter() + .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) + }); env }, relay_url: relay_url.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index f258b90a6d0..432d6cbebf8 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -226,3 +226,68 @@ fn canonical_effort_edit_changes_snapshot() { "a canonical effort edit must trip the restart badge" ); } + +/// A custom-command record whose runtime matches no known ACP runtime, so the +/// launch projection uses an EMPTY suppress set (external-review-#2 +/// pass-through) and the child actually receives its raw effort env. +fn custom_command_record() -> ManagedAgentRecord { + let mut rec = record(); + rec.agent_command_override = Some("/opt/custom/my-agent".into()); + rec +} + +#[test] +fn custom_runtime_effort_env_stays_in_snapshot_and_diffs() { + // Regression (external review, Carl): for an unknown/custom runtime the + // launch projection strips NO effort key, so the child receives the raw + // `GOOSE_THINKING_EFFORT` from the wrapper's env. The snapshot must retain + // that key as ordinary env — the projection consumed nothing into + // `effort_level` (its dest key, the ACP sentinel, is absent) — so an edit to + // it diffs the snapshot and fires the restart badge. The prior full strip + // erased the key from both places, producing NO restart diff on an effort + // edit and leaving the running agent on stale effort. + let mut high = custom_command_record(); + high.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("GOOSE_THINKING_EFFORT")) + .and_then(|v| v.as_str()), + Some("high"), + "a custom runtime's effort env must remain in the snapshot as ordinary env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + None, + "the custom sentinel dest key is absent, so effort_level captures nothing" + ); + + let mut low = custom_command_record(); + low.env_vars + .insert("GOOSE_THINKING_EFFORT".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a custom runtime's effort env must trip the restart badge" + ); +} + +#[test] +fn known_runtime_still_strips_native_effort_env_from_snapshot() { + // The counter-case pinning the scoping: for a KNOWN runtime the full sweep + // still applies, so `GOOSE_THINKING_EFFORT` reaches the snapshot only as the + // single `effort_level` field — never as a phantom `env` entry alongside it. + let canonical = snap(&record_with_env_effort("high")); + assert_eq!( + effort_env_leaf(&canonical), + None, + "a known runtime must still strip its native effort key from the snapshot env" + ); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the known runtime's effort must land solely in the effort_level field" + ); +} From aa9d0cc4137a96713154ca3c530218ac55a12a85 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 26 Aug 2026 16:53:02 -0400 Subject: [PATCH 07/33] fix(agent-config): capture mixed-case ACP sentinel in restart snapshot For an unknown/custom runtime the launch projection uses an empty suppress set, so a user-set mixed-case buzz_acp_effort_level survives into descriptor.env and the child reads it as BUZZ_ACP_EFFORT_LEVEL on Windows. effective_effort read the dest key exact-case while the snapshot strip removed every case variant, so a mixed-case sentinel landed in neither snapshot.env nor effort_level and effort edits produced no restart diff. Read the sentinel via the shared case-insensitive get_ci so read and strip agree. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../managed_agents/config_bridge/effort.rs | 2 +- .../src/managed_agents/spawn_snapshot.rs | 10 ++- .../spawn_snapshot/tests_ext.rs | 88 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index 1ef9ac4e46e..ab3b465ced9 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -85,7 +85,7 @@ impl EffortLaunch { /// then falls back to the first case-insensitive match. Effort key resolution /// must match Windows `Command` env semantics, where a mixed-case native key /// is the same variable as its canonical form. -pub(super) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { +pub(crate) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { map.get(key).or_else(|| { map.iter() .find(|(k, _)| k.eq_ignore_ascii_case(key)) diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index d5876e83eaf..49293296d97 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -153,7 +153,15 @@ pub(crate) struct SpawnConfigSnapshot { pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Option { let runtime = known_acp_runtime(&descriptor.command); let dest_key = super::config_bridge::effort::effort_dest_key(runtime); - descriptor.env.get(dest_key).cloned() + // Read case-insensitively (exact-first) so a mixed-case sentinel a custom + // runtime passed through (the projection uses an EMPTY suppress set, so a + // user-set `buzz_acp_effort_level` survives into `descriptor.env` and the + // child reads it as `BUZZ_ACP_EFFORT_LEVEL` on Windows) is captured here. + // The read must match the snapshot strip, which is also case-insensitive: + // if the read were exact-case it would miss the mixed-case sentinel, the + // strip would still remove it, and the value would land in neither + // `snapshot.env` nor `effort_level` — producing no restart diff on an edit. + super::config_bridge::effort::get_ci(&descriptor.env, dest_key).cloned() } impl SpawnConfigSnapshot { diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index 432d6cbebf8..af5a0137e60 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -291,3 +291,91 @@ fn known_runtime_still_strips_native_effort_env_from_snapshot() { "the known runtime's effort must land solely in the effort_level field" ); } + +#[test] +fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { + // Regression (external review, Carl, P2): for an unknown/custom runtime the + // launch projection uses an EMPTY suppress set, so a user-set mixed-case + // `buzz_acp_effort_level` survives into `descriptor.env` and the child reads + // it as `BUZZ_ACP_EFFORT_LEVEL` on Windows. `effective_effort` now reads the + // sentinel case-insensitively (exact-first `get_ci`), so it captures that + // pass-through value into `effort_level` — matching the case-insensitive + // snapshot strip. Before the fix the exact-case read missed the mixed-case + // key while the strip still removed it, so the value vanished from BOTH + // fields and an edit produced no restart diff. + let mut high = custom_command_record(); + high.env_vars + .insert("buzz_acp_effort_level".into(), "high".into()); + let canonical = snap(&high); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "a mixed-case pass-through sentinel must be captured into effort_level" + ); + assert_eq!( + canonical + .get("env") + .and_then(|env| env.get("buzz_acp_effort_level")), + None, + "the sentinel is the projection's dest key and is stripped from env once represented" + ); + + // The mutation pin: editing the mixed-case sentinel must trip the badge. + // Reverting the fix (exact-case read + case-insensitive strip) makes both + // snapshots carry `effort_level = null` with the key stripped, so they + // compare equal and this assertion fails. + let mut low = custom_command_record(); + low.env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low), + canonical, + "editing a mixed-case custom-runtime sentinel must trip the restart badge" + ); +} + +#[test] +fn custom_runtime_canonical_column_wins_over_mixed_case_sentinel() { + // The with-canonical-column collision case Carl asked for. A custom runtime + // resolves its effective effort over the canonical column (the sentinel is + // transport for an unknown runtime, never an authority tier), and the + // projection emits that value under the canonical `BUZZ_ACP_EFFORT_LEVEL`. + // `effective_effort`'s exact-first `get_ci` reads that canonical emission, + // so the column wins `effort_level` — the one representation matching the + // value the adapter reads on its canonical sentinel key. Both case variants + // are then stripped from `env`, leaving no duplicate effort representation. + let mut high_col = custom_command_record(); + high_col.effort_level = Some("high".into()); + high_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + let canonical = snap(&high_col); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("high"), + "the canonical column wins effort_level over the pass-through sentinel" + ); + let env = canonical.get("env").expect("snapshot has an env object"); + assert_eq!( + env.get("BUZZ_ACP_EFFORT_LEVEL"), + None, + "the projection-emitted canonical sentinel is stripped from env" + ); + assert_eq!( + env.get("buzz_acp_effort_level"), + None, + "the user's mixed-case sentinel duplicate is stripped case-insensitively" + ); + + // Editing the authority (the column) still trips the badge. + let mut low_col = custom_command_record(); + low_col.effort_level = Some("low".into()); + low_col + .env_vars + .insert("buzz_acp_effort_level".into(), "low".into()); + assert_ne!( + snap(&low_col), + canonical, + "editing the canonical column must trip the restart badge" + ); +} From 0c814efc49b9d543f10b3b7b745fa3aecd3e40ae Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 26 Aug 2026 18:24:13 -0400 Subject: [PATCH 08/33] fix(desktop): reconcile ACP effort sentinel case in the launch projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an unknown/custom runtime the effort launch projection used an empty suppress set, so a user-set mixed-case buzz_acp_effort_level survived into descriptor.env alongside the canonical BUZZ_ACP_EFFORT_LEVEL the projection emits. Windows Command case-folds env keys with later-set winning, so the child received the shadowing variant while the snapshot read the canonical one — the column did not actually win, and a no-column mixed-case sentinel landed in neither snapshot field. Suppress the sentinel (only) for unknown runtimes and re-emit any pass-through value canonically, so descriptor.env carries at most one sentinel spelling. Every downstream consumer — child, restart snapshot, badge — then reads one truth regardless of platform env-write order. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../managed_agents/config_bridge/effort.rs | 60 ++++++++++---- .../config_bridge/effort_tests.rs | 78 +++++++++++++++++-- .../spawn_snapshot/tests_ext.rs | 43 +++++----- 3 files changed, 137 insertions(+), 44 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index ab3b465ced9..659ac60ac51 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -58,6 +58,13 @@ pub(crate) struct EffortLaunch { /// Always includes the sentinel and all known native/legacy effort keys, so /// no foreign or transport effort key can shadow the projected authority. pub suppress: Vec<&'static str>, + /// When no tier resolved a `value`, preserve a value the launch env already + /// carries under `key` (collapsing every case variant to the canonical + /// spelling). Set only for unknown/custom runtimes, where the ACP sentinel + /// is user pass-through transport that must survive a spawn — not a foreign + /// key to drop. Known runtimes leave it `false`: a bare destination-key + /// value with no resolved authority is invalid/foreign and is dropped. + pub preserve_passthrough: bool, } impl EffortLaunch { @@ -68,14 +75,24 @@ impl EffortLaunch { /// Suppression is ASCII-case-insensitive: Windows `Command` case-folds env /// names, so a hand-set `goose_thinking_effort` would otherwise evade an /// exact-case strip and shadow the projected authority. + /// + /// When `preserve_passthrough` is set and no tier resolved a value, a value + /// already present under `key` (in any case) is carried forward and + /// re-emitted canonically — read from the fully layered env, so the + /// surviving value is exactly what the child would receive after Windows + /// case-folds duplicate spellings. This keeps an unknown/custom runtime's + /// hand-set sentinel alive while guaranteeing one canonical spelling. pub(crate) fn apply(&self, env: &mut BTreeMap) { + let carried = (self.value.is_none() && self.preserve_passthrough) + .then(|| get_ci(env, self.key).cloned()) + .flatten(); env.retain(|k, _| { !self .suppress .iter() .any(|suppressed| k.eq_ignore_ascii_case(suppressed)) }); - if let Some(ref v) = self.value { + if let Some(v) = self.value.as_ref().or(carried.as_ref()) { env.insert(self.key.to_string(), v.clone()); } } @@ -213,11 +230,12 @@ pub(crate) fn effort_suppress_keys() -> Vec<&'static str> { /// every effort key to the single destination key, so this removes only that /// destination key (a no-op on the already-swept siblings). /// - **unknown/custom runtime** — only the ACP-startup sentinel. The projection -/// used an EMPTY suppress set here (external-review-#2 pass-through), leaving -/// every other effort-looking key (e.g. a hand-rolled `GOOSE_THINKING_EFFORT`) -/// untouched as ordinary env. Those must remain in `env` so an edit to them -/// diffs the snapshot normally; only the sentinel — the key the projection -/// emits and `effective_effort` reads into `effort_level` — is removed. +/// suppresses just the sentinel here (reconciling every case variant to the +/// canonical spelling — external review, Carl P2), leaving every other +/// effort-looking key (e.g. a hand-rolled `GOOSE_THINKING_EFFORT`) untouched +/// as ordinary env. Those must remain in `env` so an edit to them diffs the +/// snapshot normally; only the sentinel — the key the projection emits and +/// `effective_effort` reads into `effort_level` — is removed. pub(crate) fn snapshot_suppress_keys(runtime: Option<&KnownAcpRuntime>) -> Vec<&'static str> { if runtime.is_some() { effort_suppress_keys() @@ -243,20 +261,29 @@ pub(crate) fn effort_launch_projection( ) -> EffortLaunch { let key = effort_dest_key(runtime); - // Fix (external review #2): unknown/custom runtimes restore main's - // pass-through. With no runtime metadata there is no vocabulary to bridge - // and no known env-tier authority, so suppressing every effort key would - // silently strip a working custom-wrapper key (e.g. `GOOSE_THINKING_EFFORT` - // on a hand-rolled Goose adapter). An empty suppress set leaves user and - // definition effort env untouched; the raw canonical column still emits - // under `BUZZ_ACP_EFFORT_LEVEL` (the retained compatibility path), matching - // main. KNOWN runtimes — including Claude/Codex with `thinking_env_var: - // None` — keep the full sweep + single-key emission. + // Suppress the full effort vocabulary for KNOWN runtimes. For an + // unknown/custom runtime (external review #2) we keep every foreign + // effort-looking key as pass-through — a hand-rolled `GOOSE_THINKING_EFFORT` + // on a custom Goose wrapper must reach the child untouched — EXCEPT our own + // ACP-startup sentinel, which we always reconcile to a single canonical + // spelling (external review, Carl P2): the projection emits the sentinel, so + // a user-set case variant (e.g. `buzz_acp_effort_level`) is never intentional + // config, and leaving one to shadow the emitted `BUZZ_ACP_EFFORT_LEVEL` on + // Windows (where `Command` case-folds env names) would hand the child a + // different value than the snapshot reads. Stripping the sentinel here and + // re-emitting canonically guarantees at most ONE sentinel spelling downstream, + // so the child, the restart snapshot, and the badge cannot disagree on case. let suppress = if runtime.is_some() { effort_suppress_keys() } else { - Vec::new() + vec![ACP_STARTUP_EFFORT_KEY] }; + // When no tier resolves a value, an unknown runtime still preserves a + // hand-set sentinel the user routed to the child (the retained pass-through + // from external review #2) — carried forward and re-emitted canonically by + // `apply`. Known runtimes never preserve a bare dest-key value: it is either + // the projection's own emission or a foreign key, both handled by `value`. + let preserve_passthrough = runtime.is_none(); // Value gate: Goose canonicalizes through its alias contract; buzz-agent // validates against its accepted set (invalid → skip, so a foreign @@ -286,6 +313,7 @@ pub(crate) fn effort_launch_projection( value, key, suppress, + preserve_passthrough, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index 2055a542515..bc64c9c809c 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -477,15 +477,18 @@ fn buzz_agent_generic_column_does_not_leak_acp_sentinel() { #[test] fn unknown_runtime_does_not_suppress_user_effort_env() { // Regression: a custom wrapper with GOOSE_THINKING_EFFORT=high in record env - // must receive it unchanged. On main an unset column left env untouched; - // the projection must not strip effort keys for a runtime it has no - // metadata for (empty suppress set = pass-through). + // must receive it unchanged. On main an unset column left env untouched; the + // projection must not strip a foreign effort key for a runtime it has no + // metadata for. Only our own ACP-startup sentinel is reconciled (external + // review, Carl P2), so `suppress` is exactly `[BUZZ_ACP_EFFORT_LEVEL]` — no + // foreign key. let mut r = record(); r.env_vars = env(&[(GOOSE_KEY, "high"), ("UNRELATED", "keep")]); let launch = project_record_only(&r, None); - assert!( - launch.suppress.is_empty(), - "unknown runtime suppresses nothing" + assert_eq!( + launch.suppress, + vec![ACP_KEY], + "unknown runtime suppresses only its own sentinel, never a foreign key" ); let mut launch_env = r.env_vars.clone(); @@ -504,12 +507,14 @@ fn unknown_runtime_does_not_suppress_user_effort_env() { #[test] fn unknown_runtime_keeps_user_acp_sentinel_when_no_column() { // A custom adapter with a hand-set BUZZ_ACP_EFFORT_LEVEL and no canonical - // column keeps its sentinel — nothing to project, nothing suppressed. + // column keeps its sentinel value: nothing to project, but the pass-through + // is preserved and re-emitted canonically so the child still receives it. let mut r = record(); r.env_vars = env(&[(ACP_KEY, "low")]); let launch = project_record_only(&r, None); - // No column → no projected value → nothing emitted, nothing stripped. + // No column → no projected value; the sentinel is carried through `apply`. assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); let mut launch_env = r.env_vars.clone(); launch.apply(&mut launch_env); assert_eq!( @@ -519,6 +524,63 @@ fn unknown_runtime_keeps_user_acp_sentinel_when_no_column() { ); } +#[test] +fn unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column() { + // External review, Carl P2 (no-column half): a hand-set MIXED-CASE sentinel + // on a custom runtime must survive AND be normalized to the canonical + // spelling. Windows `Command` case-folds env names, so leaving the lowercase + // variant would hand the child a value the exact-case snapshot read misses. + // After the projection exactly one canonical spelling remains, carrying the + // pass-through value — so child, snapshot, and badge cannot disagree on case. + let mut r = record(); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "the mixed-case pass-through sentinel is re-emitted under the canonical key" + ); + assert_eq!( + launch_env.get("buzz_acp_effort_level"), + None, + "the mixed-case spelling is collapsed away, leaving exactly one sentinel" + ); +} + +#[test] +fn unknown_runtime_column_wins_over_mixed_case_sentinel() { + // External review, Carl P2 (with-column half): a canonical column plus a + // hand-set mixed-case sentinel. The column is the authority (the sentinel is + // transport for an unknown runtime), and the projection must strip EVERY case + // variant of the sentinel before emitting the column value — so the child + // receives the column value, not the shadowing lowercase variant Windows + // would otherwise fold onto the canonical key. + let mut r = record(); + r.effort_level = Some("high".into()); + r.env_vars = env(&[("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value.as_deref(), Some("high")); + assert_eq!(launch.key, ACP_KEY); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("high"), + "the column value wins and is emitted under the canonical sentinel key" + ); + assert_eq!( + launch_env.get("buzz_acp_effort_level"), + None, + "the shadowing mixed-case sentinel is stripped so the column truly wins" + ); + // Mutation: reverting the unknown-runtime suppress set to empty leaves + // `buzz_acp_effort_level=low` in the child env, so the column would NOT win + // on Windows and this case-variant assertion fails. +} + #[test] fn unknown_runtime_column_still_emits_under_acp_sentinel() { // The retained compatibility emission: an unknown runtime with a canonical diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index af5a0137e60..e5a82e39a03 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -294,15 +294,16 @@ fn known_runtime_still_strips_native_effort_env_from_snapshot() { #[test] fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { - // Regression (external review, Carl, P2): for an unknown/custom runtime the - // launch projection uses an EMPTY suppress set, so a user-set mixed-case - // `buzz_acp_effort_level` survives into `descriptor.env` and the child reads - // it as `BUZZ_ACP_EFFORT_LEVEL` on Windows. `effective_effort` now reads the - // sentinel case-insensitively (exact-first `get_ci`), so it captures that - // pass-through value into `effort_level` — matching the case-insensitive - // snapshot strip. Before the fix the exact-case read missed the mixed-case - // key while the strip still removed it, so the value vanished from BOTH - // fields and an edit produced no restart diff. + // Regression (external review, Carl, P2): for an unknown/custom runtime a + // user-set mixed-case `buzz_acp_effort_level` (no column) must not vanish. + // The launch projection now reconciles it — stripping the mixed-case + // spelling and re-emitting the pass-through value under the canonical + // `BUZZ_ACP_EFFORT_LEVEL` (see `effort_tests:: + // unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column`) + // — so `descriptor.env` carries exactly one canonical sentinel. The snapshot + // captures it into `effort_level` and strips it from `env`. Before the r4/r5 + // fixes the mixed-case key survived while the exact-case read missed it, so + // the value vanished from BOTH fields and an edit produced no restart diff. let mut high = custom_command_record(); high.env_vars .insert("buzz_acp_effort_level".into(), "high".into()); @@ -321,9 +322,10 @@ fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { ); // The mutation pin: editing the mixed-case sentinel must trip the badge. - // Reverting the fix (exact-case read + case-insensitive strip) makes both - // snapshots carry `effort_level = null` with the key stripped, so they - // compare equal and this assertion fails. + // Reverting the fix (exact-case read + case-insensitive strip, or an empty + // unknown-runtime suppress set) makes both snapshots carry + // `effort_level = null` with the key stripped, so they compare equal and + // this assertion fails. let mut low = custom_command_record(); low.env_vars .insert("buzz_acp_effort_level".into(), "low".into()); @@ -336,14 +338,15 @@ fn custom_runtime_mixed_case_sentinel_is_captured_not_lost() { #[test] fn custom_runtime_canonical_column_wins_over_mixed_case_sentinel() { - // The with-canonical-column collision case Carl asked for. A custom runtime - // resolves its effective effort over the canonical column (the sentinel is - // transport for an unknown runtime, never an authority tier), and the - // projection emits that value under the canonical `BUZZ_ACP_EFFORT_LEVEL`. - // `effective_effort`'s exact-first `get_ci` reads that canonical emission, - // so the column wins `effort_level` — the one representation matching the - // value the adapter reads on its canonical sentinel key. Both case variants - // are then stripped from `env`, leaving no duplicate effort representation. + // The with-canonical-column collision case Carl asked for, verified at the + // SNAPSHOT here and — decisively — at the projection/descriptor seam in + // `effort_tests::unknown_runtime_column_wins_over_mixed_case_sentinel`. The + // projection strips every case variant of the sentinel before emitting the + // column value, so `descriptor.env` carries exactly `BUZZ_ACP_EFFORT_LEVEL= + // ` and the child receives the column value on every platform (no + // lowercase variant survives for Windows to case-fold over the canonical + // key). This snapshot therefore reads the same truth the child gets: the + // column wins `effort_level` and both case variants are absent from `env`. let mut high_col = custom_command_record(); high_col.effort_level = Some("high".into()); high_col From 8be50fc30e8a3756a7ed29349315f608ef256cc0 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 26 Aug 2026 18:43:45 -0400 Subject: [PATCH 09/33] fix(config_bridge): preserve Windows effective sentinel value in no-column carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an unknown/custom runtime with no canonical column and the ACP sentinel hand-set in multiple case spellings across env tiers (e.g. lower-tier BUZZ_ACP_EFFORT_LEVEL plus higher-tier buzz_acp_effort_level), the r5 preserve_passthrough carry read exact-case-first via get_ci and picked the canonical spelling. Rust's Windows Command writer sets each spelling in BTreeMap iteration order into a case-folded env map (last set wins), so the child received the lexicographically-last variant instead. The carry now selects the last case-insensitive match in iteration order, matching what the child actually gets, then re-emits it canonically — so one downstream spelling carries the effective value rather than a deterministically wrong one. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../managed_agents/config_bridge/effort.rs | 20 +++++++++--- .../config_bridge/effort_tests.rs | 31 +++++++++++++++++++ .../spawn_snapshot/tests_ext.rs | 11 ++++--- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index 659ac60ac51..000d97161e2 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -78,13 +78,23 @@ impl EffortLaunch { /// /// When `preserve_passthrough` is set and no tier resolved a value, a value /// already present under `key` (in any case) is carried forward and - /// re-emitted canonically — read from the fully layered env, so the - /// surviving value is exactly what the child would receive after Windows - /// case-folds duplicate spellings. This keeps an unknown/custom runtime's - /// hand-set sentinel alive while guaranteeing one canonical spelling. + /// re-emitted canonically. Multiple case spellings can survive the + /// case-sensitive layer merge (e.g. a lower-tier `BUZZ_ACP_EFFORT_LEVEL` + /// plus a higher-tier `buzz_acp_effort_level`); the carry selects the LAST + /// case-insensitive match in `BTreeMap` iteration order, which is exactly + /// the value Rust's Windows `Command` writer produces — it sets each spelling + /// in iteration order into a case-folded env map, so the last set wins. This + /// keeps an unknown/custom runtime's hand-set sentinel alive, preserves the + /// value the child would actually receive, and guarantees one canonical + /// spelling downstream. pub(crate) fn apply(&self, env: &mut BTreeMap) { let carried = (self.value.is_none() && self.preserve_passthrough) - .then(|| get_ci(env, self.key).cloned()) + .then(|| { + env.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(self.key)) + .map(|(_, v)| v.clone()) + }) .flatten(); env.retain(|k, _| { !self diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index bc64c9c809c..3dc25f75a10 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -550,6 +550,37 @@ fn unknown_runtime_collapses_mixed_case_sentinel_to_canonical_when_no_column() { ); } +#[test] +fn unknown_runtime_no_column_multi_variant_preserves_windows_effective_value() { + // Pass-3 IMPORTANT (Thufir): both case spellings of the sentinel survive the + // case-sensitive layer merge — a lower-tier canonical `BUZZ_ACP_EFFORT_LEVEL` + // plus a higher-tier record override `buzz_acp_effort_level` — with NO column. + // The carry must preserve the value the Windows child would actually receive. + // Rust's `Command` writes each spelling in `BTreeMap` iteration order into a + // case-folded env map (last set wins); canonical `B` sorts before lowercase + // `b`, so the lowercase/higher-tier `low` is written last and wins. The carry + // selects the LAST case-insensitive match in iteration order, matching that. + let mut r = record(); + r.env_vars = env(&[(ACP_KEY, "high"), ("buzz_acp_effort_level", "low")]); + let launch = project_record_only(&r, None); + assert_eq!(launch.value, None); + assert!(launch.preserve_passthrough); + let mut launch_env = r.env_vars.clone(); + launch.apply(&mut launch_env); + assert_eq!( + launch_env.get(ACP_KEY).map(String::as_str), + Some("low"), + "the carry preserves the last-in-iteration-order value the Windows child receives" + ); + assert_eq!( + launch_env.get("buzz_acp_effort_level"), + None, + "every case variant collapses to exactly one canonical sentinel" + ); + // Mutation: reverting the carry to exact-first `get_ci` selects canonical + // `high` instead, inverting the pass-through value and re-breaking this pin. +} + #[test] fn unknown_runtime_column_wins_over_mixed_case_sentinel() { // External review, Carl P2 (with-column half): a canonical column plus a diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index e5a82e39a03..8fd9f5bafe1 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -228,8 +228,9 @@ fn canonical_effort_edit_changes_snapshot() { } /// A custom-command record whose runtime matches no known ACP runtime, so the -/// launch projection uses an EMPTY suppress set (external-review-#2 -/// pass-through) and the child actually receives its raw effort env. +/// launch projection suppresses ONLY its own ACP sentinel (external-review-#2 +/// pass-through, r5): every foreign effort key survives untouched and the child +/// receives its raw effort env. fn custom_command_record() -> ManagedAgentRecord { let mut rec = record(); rec.agent_command_override = Some("/opt/custom/my-agent".into()); @@ -239,9 +240,9 @@ fn custom_command_record() -> ManagedAgentRecord { #[test] fn custom_runtime_effort_env_stays_in_snapshot_and_diffs() { // Regression (external review, Carl): for an unknown/custom runtime the - // launch projection strips NO effort key, so the child receives the raw - // `GOOSE_THINKING_EFFORT` from the wrapper's env. The snapshot must retain - // that key as ordinary env — the projection consumed nothing into + // launch projection strips only its own ACP sentinel, so the child receives + // the raw `GOOSE_THINKING_EFFORT` from the wrapper's env. The snapshot must + // retain that key as ordinary env — the projection consumed nothing into // `effort_level` (its dest key, the ACP sentinel, is absent) — so an edit to // it diffs the snapshot and fires the restart badge. The prior full strip // erased the key from both places, producing NO restart diff on an effort From 38a6f700226288ac272ad5c44770902647d72b66 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 27 Aug 2026 10:18:42 -0400 Subject: [PATCH 10/33] fix(agents): persist global effort to the runtime native key The global/onboarding effort control set currentPersistence.key (and its displayed value) to BUZZ_AGENT_THINKING_EFFORT for every runtime with a thinkingEnvVar, while only targetApplication used the native key. The launch projection's global tier reads native-only (the legacy alias is record/persona scope), so selecting Goose effort saved BUZZ_AGENT_THINKING_EFFORT, round-tripped in the UI, and was silently ignored by the next Goose spawn. Persist and read through runtime.thinkingEnvVar at both scopes; buzz-agent is unchanged, Goose now writes GOOSE_THINKING_EFFORT. A pre-existing stale legacy key stays an inert generic env row rather than masquerading as the effort value. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/lib/agentConfigCore.test.mjs | 48 ++++++++++++++++++- .../features/agents/lib/agentConfigCore.ts | 13 +++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 92159ff2754..a5686d86a0b 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -85,14 +85,60 @@ test("Goose exposes provider, model, and its real effort application key", () => ); assert.deepEqual(field(model, "effort").currentPersistence, { kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", + key: "GOOSE_THINKING_EFFORT", }); assert.deepEqual(field(model, "effort").targetApplication, { kind: "envVar", key: "GOOSE_THINKING_EFFORT", }); + // Goose reads/writes its native key at global scope — the launch projection's + // global tier is native-only, so the legacy BUZZ_AGENT_THINKING_EFFORT in the + // config is not surfaced as the effort value (it would be silently ignored). + assert.equal(field(model, "effort").value, null); }); +// Carl (review 5036131024): global/onboarding effort persistence must use the +// runtime's native key so a selection reaches the spawn. The launch projection's +// global tier reads native-only (legacy alias is record/persona-scope), so +// persisting the legacy key for Goose round-trips in the UI but is ignored at +// spawn. Both scopes derive the same persistence/application key. +for (const scope of ["global", "onboarding"]) { + test(`effort persists to the runtime native key at ${scope} scope`, () => { + const goose = deriveAgentConfigFieldModel({ + config: { ...config, env_vars: { GOOSE_THINKING_EFFORT: "high" } }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const gooseEffort = field(goose, "effort"); + assert.deepEqual(gooseEffort.currentPersistence, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.deepEqual(gooseEffort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(gooseEffort.value, "high"); + assert.deepEqual(structuredEnvKeys([gooseEffort]), [ + "GOOSE_THINKING_EFFORT", + ]); + + const buzz = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + }), + scope, + }); + const buzzEffort = field(buzz, "effort"); + assert.deepEqual(buzzEffort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.equal(buzzEffort.value, "high"); + }); +} + test("Claude models effort as a deferred native ACP option", () => { const model = deriveAgentConfigFieldModel({ config, diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5a8b8cb1c37..1233659d514 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -2,7 +2,6 @@ import type { AcpRuntimeCatalogEntry, GlobalAgentConfig, } from "@/shared/api/types"; -import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig"; /** * Lifecycle status of the ACP runtime catalog query on a per-agent surface. @@ -204,6 +203,12 @@ export function deriveAgentConfigFieldModel({ }); if (runtime?.thinkingEnvVar) { + // Global/onboarding persists to the runtime's native key — the same key the + // launch projection reads at global tier (native-only; the legacy alias is + // record/persona-scope only). For buzz-agent this IS BUZZ_AGENT_THINKING_EFFORT; + // for Goose it is GOOSE_THINKING_EFFORT, so a global selection actually reaches + // the spawn rather than persisting a legacy key the projection ignores. + const thinkingKey = runtime.thinkingEnvVar; fields.push({ kind: "effort", optionSource: @@ -212,11 +217,11 @@ export function deriveAgentConfigFieldModel({ : "legacyProviderModelCatalog", currentPersistence: { kind: "envVar", - key: BUZZ_AGENT_THINKING_EFFORT, + key: thinkingKey, }, - targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar }, + targetApplication: { kind: "envVar", key: thinkingKey }, render: "control", - value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), + value: valueFromEnv(config, thinkingKey), }); } else if (runtime?.id === "claude") { fields.push({ From 97876e054fdb8ac65b707df21e8e1c896f67c483 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 27 Aug 2026 10:31:22 -0400 Subject: [PATCH 11/33] fix(agents): scope the effort native-key persistence to global/onboarding The r7 change switched effort currentPersistence/value to the runtime native key unconditionally, silently altering the descriptor contract for the definition and instance scopes too. Per agents/AGENTS.md rule 2, per-agent effort intentionally stays on the generic legacy BUZZ_AGENT_THINKING_EFFORT row until PR 2.7 migrates Goose/Claude. Gate the native-key persistence to scope === global || onboarding; keep the legacy key for definition/instance. targetApplication stays native for every scope, as it was pre-r7. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/lib/agentConfigCore.test.mjs | 30 +++++++++++++++++++ .../features/agents/lib/agentConfigCore.ts | 27 +++++++++++------ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index a5686d86a0b..02315cfb6f6 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -139,6 +139,36 @@ for (const scope of ["global", "onboarding"]) { }); } +// Per-agent scopes (definition/instance) intentionally keep effort on the +// generic legacy BUZZ_AGENT_THINKING_EFFORT row until PR 2.7 migrates Goose — +// currentPersistence/value stay legacy while targetApplication is native +// (agents/AGENTS.md rule 2). The scope gate must not broaden to these scopes. +for (const scope of ["definition", "instance"]) { + test(`Goose effort stays on the legacy persistence key at ${scope} scope`, () => { + const model = deriveAgentConfigFieldModel({ + config: { + ...config, + env_vars: { + BUZZ_AGENT_THINKING_EFFORT: "high", + GOOSE_THINKING_EFFORT: "low", + }, + }, + runtime: runtime("goose", { thinkingEnvVar: "GOOSE_THINKING_EFFORT" }), + scope, + }); + const effort = field(model, "effort"); + assert.deepEqual(effort.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_THINKING_EFFORT", + }); + assert.deepEqual(effort.targetApplication, { + kind: "envVar", + key: "GOOSE_THINKING_EFFORT", + }); + assert.equal(effort.value, "high"); + }); +} + test("Claude models effort as a deferred native ACP option", () => { const model = deriveAgentConfigFieldModel({ config, diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 1233659d514..a3e0fc13dad 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -2,6 +2,7 @@ import type { AcpRuntimeCatalogEntry, GlobalAgentConfig, } from "@/shared/api/types"; +import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig"; /** * Lifecycle status of the ACP runtime catalog query on a per-agent surface. @@ -203,12 +204,20 @@ export function deriveAgentConfigFieldModel({ }); if (runtime?.thinkingEnvVar) { - // Global/onboarding persists to the runtime's native key — the same key the - // launch projection reads at global tier (native-only; the legacy alias is - // record/persona-scope only). For buzz-agent this IS BUZZ_AGENT_THINKING_EFFORT; - // for Goose it is GOOSE_THINKING_EFFORT, so a global selection actually reaches - // the spawn rather than persisting a legacy key the projection ignores. - const thinkingKey = runtime.thinkingEnvVar; + // targetApplication is always the runtime's native key — how the harness + // should receive effort. currentPersistence (where the value lives today) + // is scope-split until PR 2.7 migrates per-agent Goose/Claude: + // - global/onboarding: native key, matching the launch projection's global + // tier (native-only; the legacy alias is record/persona scope), so a + // selection actually reaches the spawn rather than persisting a key the + // projection ignores. For buzz-agent this IS BUZZ_AGENT_THINKING_EFFORT. + // - definition/instance: still the generic legacy BUZZ_AGENT_THINKING_EFFORT + // row, unchanged pending the per-agent migration. + const nativeKey = runtime.thinkingEnvVar; + const persistenceKey = + scope === "global" || scope === "onboarding" + ? nativeKey + : BUZZ_AGENT_THINKING_EFFORT; fields.push({ kind: "effort", optionSource: @@ -217,11 +226,11 @@ export function deriveAgentConfigFieldModel({ : "legacyProviderModelCatalog", currentPersistence: { kind: "envVar", - key: thinkingKey, + key: persistenceKey, }, - targetApplication: { kind: "envVar", key: thinkingKey }, + targetApplication: { kind: "envVar", key: nativeKey }, render: "control", - value: valueFromEnv(config, thinkingKey), + value: valueFromEnv(config, persistenceKey), }); } else if (runtime?.id === "claude") { fields.push({ From c8ce662bd1f25542f99155f93421579d8397e37c Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 12:40:55 -0400 Subject: [PATCH 12/33] fix(config_bridge): resolve effort by last case variant on Windows get_ci preferred an exact-case match, but Windows Command populates env in BTreeMap iteration order and case-folds names, so the last-set spelling wins and is the value the child actually receives. For a known runtime with GOOSE_THINKING_EFFORT=low plus goose_thinking_effort=high, resolution picked low while the child ran high. Select the last case-insensitive match instead, mirroring EffortLaunch::apply's existing .rev().find passthrough carry, so the tier reader, the carry, and the child agree on one value across all six effort.rs callers and the four reader.rs siblings sharing the helper. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../managed_agents/config_bridge/effort.rs | 23 +++++++++++-------- .../config_bridge/effort_tests.rs | 21 +++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs index 000d97161e2..248b23abcfa 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort.rs @@ -108,16 +108,21 @@ impl EffortLaunch { } } -/// Look up `key` in `map` case-insensitively (ASCII). Prefers an exact match, -/// then falls back to the first case-insensitive match. Effort key resolution -/// must match Windows `Command` env semantics, where a mixed-case native key -/// is the same variable as its canonical form. +/// Look up `key` in `map` case-insensitively (ASCII), selecting the LAST +/// case-insensitive match in `BTreeMap` iteration order. Effort key resolution +/// must match Windows `Command` env semantics: `Command` writes each spelling +/// in iteration order into a case-folded env map, so the last-set spelling wins +/// and is the value the child actually receives. Preferring an exact match +/// instead would pick a different case variant than the child gets — e.g. +/// `GOOSE_THINKING_EFFORT=low` plus `goose_thinking_effort=high` would resolve +/// to `low` while the child runs `high`. This mirrors `EffortLaunch::apply`'s +/// `.rev().find` carry so the tier reader, the passthrough carry, and the child +/// all agree on one value. pub(crate) fn get_ci<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a String> { - map.get(key).or_else(|| { - map.iter() - .find(|(k, _)| k.eq_ignore_ascii_case(key)) - .map(|(_, v)| v) - }) + map.iter() + .rev() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v) } /// Resolve the single harness-agnostic effort authority and apply it to a fully diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index 3dc25f75a10..1807cc99438 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -696,6 +696,27 @@ fn mixed_case_native_key_is_read_and_wins() { ); } +#[test] +fn duplicate_case_native_variants_resolve_to_windows_effective_value() { + // Carl P2 (r8): both case spellings of a known runtime's native key survive + // the case-sensitive record env map. Windows `Command` writes each spelling + // in `BTreeMap` iteration order into a case-folded env map, so the LAST-set + // spelling wins and is the value the child receives. Canonical + // `GOOSE_THINKING_EFFORT` sorts before lowercase `goose_thinking_effort`, so + // the lowercase `high` is written last and is the child's effective value. + // `get_ci` must select that last match, not the exact-case `low`. + let mut r = record(); + r.env_vars = env(&[(GOOSE_KEY, "low"), ("goose_thinking_effort", "high")]); + let launch = project_record_only(&r, Some(goose())); + assert_eq!( + launch.value.as_deref(), + Some("high"), + "known-runtime native lookup selects the last case variant Windows Command sets" + ); + // Mutation: reverting `get_ci` to exact-first selects the canonical `low`, + // inverting the effective effort and re-breaking this pin. +} + #[test] fn apply_strips_mixed_case_effort_keys() { // A hand-set mixed-case foreign effort key must be swept, not left to From 274e380692b0f4ee1e9a8953370027842d76bc89 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 13:53:52 -0400 Subject: [PATCH 13/33] refactor(agents): extract the provider/model fields into a section component AgentInstanceEditDialog sits at its file-size ratchet cap, so the r8 effort Save-gate cannot land while its wiring lives inline. Lift the cohesive LLM provider + provider API key + model block into EditAgentProviderModelFields as a pure move: props in, byte-identical JSX out, no behavior change. This reclaims the dialog headroom the Save-gate commit needs. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/AgentInstanceEditDialog.tsx | 161 ++++----------- .../ui/EditAgentProviderModelFields.tsx | 191 ++++++++++++++++++ 2 files changed, 226 insertions(+), 126 deletions(-) create mode 100644 desktop/src/features/agents/ui/EditAgentProviderModelFields.tsx diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 62d85d385d4..630b9586272 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -40,7 +40,6 @@ import { NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, - PERSONA_LABEL_OPTIONAL_CLASS, runtimeSupportsLlmProviderSelection, shouldClearKnownModelForSelectionScope, sortPersonaRuntimes, @@ -74,15 +73,12 @@ import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; -import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; +import { EditAgentProviderModelFields } from "./EditAgentProviderModelFields"; import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { - getProviderApiKeyEnvVar, - getProviderApiKeyLabel, -} from "./agentConfigOptions"; +import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -1005,126 +1001,39 @@ export function AgentInstanceEditDialog({ ) : null} - {/* LLM provider */} - {llmProviderFieldVisible ? ( -
- - - {isCustomProviderEditing ? ( -
- setProvider(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> -
- ) : null} -
- ) : null} - - {llmProviderFieldVisible && topLevelSecretEnvVar ? ( - { - setEnvVars((prev) => ({ - ...prev, - [topLevelSecretEnvVar]: next, - })); - }} - value={apiKeyValue} - /> - ) : null} - - {/* Model */} -
- - - {showCustomModelInput ? ( -
- setModel(event.target.value)} - placeholder="Custom model ID" - value={model} - /> -
- ) : null} - {modelStatusMessage ? ( -

- {modelStatusMessage} -

- ) : null} -
+ {/* LLM provider + provider API key + model */} + { + setEnvVars((prev) => ({ + ...prev, + [topLevelSecretEnvVar as string]: next, + })); + }} + modelRequired={modelRequired} + modelDiscoveryLoading={modelDiscoveryLoading} + modelDropdownOptions={modelDropdownOptions} + modelSelectValue={modelSelectValue} + onModelDropdownChange={handleModelDropdownChange} + showCustomModelInput={showCustomModelInput} + model={model} + onModelChange={setModel} + modelStatusMessage={modelStatusMessage} + /> diff --git a/desktop/src/features/agents/ui/EditAgentProviderModelFields.tsx b/desktop/src/features/agents/ui/EditAgentProviderModelFields.tsx new file mode 100644 index 00000000000..6cebb33c657 --- /dev/null +++ b/desktop/src/features/agents/ui/EditAgentProviderModelFields.tsx @@ -0,0 +1,191 @@ +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; + +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + PERSONA_LABEL_OPTIONAL_CLASS, + getProviderApiKeyLabel, + type PersonaDropdownOption, +} from "./agentConfigOptions"; +import { PersonaDropdownField } from "./PersonaDropdownField"; +import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; + +/** + * LLM provider + provider API key + model block of the Edit Agent dialog. + * + * Extracted verbatim from `AgentInstanceEditDialog` as one cohesive unit: the + * provider selection, the top-level API-key pseudo-field that appears for + * secret-requiring providers, and the model picker that depends on the chosen + * provider. Purely presentational — all state and handlers are owned by the + * dialog and passed in; the render is byte-identical to the inlined version. + */ +export function EditAgentProviderModelFields({ + disabled, + llmProviderFieldVisible, + providerRequired, + providerDropdownOptions, + providerSelectValue, + onProviderDropdownChange, + isCustomProviderEditing, + provider, + onProviderChange, + topLevelSecretEnvVar, + apiKeyIsInherited, + apiKeyInheritedLabel, + apiKeyIsRequired, + effectiveProvider, + apiKeyValue, + onApiKeyChange, + modelRequired, + modelDiscoveryLoading, + modelDropdownOptions, + modelSelectValue, + onModelDropdownChange, + showCustomModelInput, + model, + onModelChange, + modelStatusMessage, +}: { + disabled: boolean; + llmProviderFieldVisible: boolean; + providerRequired: boolean; + providerDropdownOptions: PersonaDropdownOption[]; + providerSelectValue: string; + onProviderDropdownChange: (value: string) => void; + isCustomProviderEditing: boolean; + provider: string; + onProviderChange: (value: string) => void; + topLevelSecretEnvVar: string | null; + apiKeyIsInherited: boolean; + apiKeyInheritedLabel: string; + apiKeyIsRequired: boolean; + effectiveProvider: string; + apiKeyValue: string; + onApiKeyChange: (value: string) => void; + modelRequired: boolean; + modelDiscoveryLoading: boolean; + modelDropdownOptions: PersonaDropdownOption[]; + modelSelectValue: string; + onModelDropdownChange: (value: string) => void; + showCustomModelInput: boolean; + model: string; + onModelChange: (value: string) => void; + modelStatusMessage: string | null; +}) { + return ( + <> + {/* LLM provider */} + {llmProviderFieldVisible ? ( +
+ + + {isCustomProviderEditing ? ( +
+ onProviderChange(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} +
+ ) : null} + + {llmProviderFieldVisible && topLevelSecretEnvVar ? ( + + ) : null} + + {/* Model */} +
+ + + {showCustomModelInput ? ( +
+ onModelChange(event.target.value)} + placeholder="Custom model ID" + value={model} + /> +
+ ) : null} + {modelStatusMessage ? ( +

{modelStatusMessage}

+ ) : null} +
+ + ); +} From 277581ef2a0d4c728d4ec69940a8defbb47668c0 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 13:56:36 -0400 Subject: [PATCH 14/33] fix(agents): make the effort picker a Save-gated standalone setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effort picker direct-wrote persistAgentEffortLevel on every selection, racing the dialog's own locked update_managed_agent save. A select→switch-to-Inherit→Save sequence could let a deferred effort IPC land after the save cleared the effort column, restoring the just-cleared pin; Cancel or a failed Save could likewise commit a write the user discarded. Lift effort into a controlled field: the dialog holds the pending selection and persists it on Save alone, sequenced after the locked update resolves, mirroring the setManagedAgentAutoRestart standalone-setter precedent. resolveEffortSubmission suppresses the write on the pin→inherit transition (the locked save already cleared the column + aliases there) and no-ops an unchanged selection. Folding effort into the frozen UpdateManagedAgentInput is avoided — sequencing after the locked save removes the race for zero correctness gain from reopening that request shape. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/AGENTS.md | 19 +++-- .../agents/ui/AgentInstanceEditDialog.tsx | 56 ++++++++++++++- .../features/agents/ui/EffortPickerField.tsx | 55 +++++++-------- .../ui/agentInstanceEditPinning.test.mjs | 70 +++++++++++++++++++ .../features/agents/ui/personaRuntimeModel.ts | 32 +++++++++ 5 files changed, 195 insertions(+), 37 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index f0c5d99f265..0478b6e05e1 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -204,11 +204,20 @@ with a TypeScript lookup table or an id comparison in a component. 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in - `AgentInstanceEditDialog` beside the Model block. It is direct-write, not - part of the frozen `UpdateManagedAgentInput` shape: each selection calls - `persistAgentEffortLevel` and invalidates the config-surface query, mirroring - the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and - option compute live in the pure helper `ui/effortPicker.ts` + `AgentInstanceEditDialog` beside the Model block. It is a **Save-gated + standalone setter**, not part of the frozen `UpdateManagedAgentInput` shape: + the picker is a controlled field (dialog holds the pending selection), and + Save persists it via `persistAgentEffortLevel` sequenced AFTER the locked + `update_managed_agent` resolves, mirroring the `setManagedAgentAutoRestart` + standalone-setter precedent. It must NOT persist on selection — a + direct-write on selection races the dialog's own locked save (a delayed + effort IPC can restore a pin the pin→inherit save just cleared) and can + commit a write a Cancel or failed Save should have discarded. On the + pin→inherit transition the locked save already clears the effort column and + aliases, so the setter is suppressed there (`resolveEffortSubmission`); after + persisting, invalidate the config-surface query so the panel's canonical tier + reflects the new next-spawn value. Its gating and option compute live in the + pure helper `ui/effortPicker.ts` (`effortPickerState`): the picker renders only when `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId` has been discovered from the running session (absent pre-first-session and diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 630b9586272..1a7da3fea83 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,9 +1,11 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { + agentConfigSurfaceQueryKey, useAcpRuntimesQuery, useAgentConfigSurface, useBakedBuildEnvKeysQuery, @@ -24,7 +26,10 @@ import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; -import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; +import { + persistAgentEffortLevel, + setManagedAgentAutoRestart, +} from "@/shared/api/tauriManagedAgents"; import { EffortPickerField } from "./EffortPickerField"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { @@ -54,6 +59,7 @@ import { envVarsEqual, isEditAgentProviderSaveValid, resolveAgentCommandUpdate, + resolveEffortSubmission, resolveInheritedRuntimeSubmission, resolveRuntimeProviderCapability, } from "./personaRuntimeModel"; @@ -112,6 +118,7 @@ export function AgentInstanceEditDialog({ }) { const updateMutation = useUpdateManagedAgentMutation(); const startMutation = useStartManagedAgentMutation(); + const queryClient = useQueryClient(); const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; @@ -142,6 +149,13 @@ export function AgentInstanceEditDialog({ const [envVars, setEnvVars] = React.useState(agent.envVars); const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] = React.useState(agent.autoRestartOnConfigChange); + // Effort picker is a Save-gated standalone setter: hold the pending selection + // in dialog state and persist it on Save alone (see resolveEffortSubmission / + // handleSubmit), never on selection. `effortTouched` distinguishes "user + // picked a value" from "showing the config-surface effective value", so an + // untouched Save writes nothing. + const [effortLevel, setEffortLevel] = React.useState(null); + const effortTouched = React.useRef(false); const personasQuery = usePersonasQuery(); const linkedPersona = React.useMemo( () => @@ -191,6 +205,8 @@ export function AgentInstanceEditDialog({ setIsCustomProviderEditing(false); setEnvVars(agent.envVars); setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); + setEffortLevel(null); + effortTouched.current = false; setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); setAvatarUrl(agent.avatarUrl ?? ""); @@ -730,6 +746,28 @@ export function AgentInstanceEditDialog({ autoRestartOnConfigChange, ); } + // Effort is a Save-gated standalone setter, sequenced AFTER the update + // resolves. Folding it into the frozen UpdateManagedAgentInput is + // avoided; sequencing after the locked save is what removes the r8 race + // (a selection can no longer land its own IPC between here and Save). The + // pin→inherit transition (agentCommandUpdate === "") already cleared the + // effort column + aliases inside that locked save, so resolveEffortSubmission + // suppresses the write there — re-persisting would restore the just-cleared pin. + const effortSubmission = resolveEffortSubmission({ + effortLevel, + originalEffortLevel: + configSurfaceQuery.data?.normalized.thinkingEffort?.value ?? null, + inheritTransition: agentCommandUpdate === "", + }); + if (effortTouched.current && effortSubmission.persist) { + await persistAgentEffortLevel(agent.pubkey, effortSubmission.level); + // The picker owns no mutation now, so refresh the config surface here + // (what its own onSuccess used to do) — the panel's canonical tier must + // reflect the new next-spawn value. + await queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }); + } showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); handleOpenChange(false); onUpdated?.(result.agent); @@ -1035,7 +1073,21 @@ export function AgentInstanceEditDialog({ modelStatusMessage={modelStatusMessage} /> - + { + effortTouched.current = true; + setEffortLevel(level); + }} + /> setAiDefaultsOpen(true)} diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx index a06f17ac11f..1d53d4f59c2 100644 --- a/desktop/src/features/agents/ui/EffortPickerField.tsx +++ b/desktop/src/features/agents/ui/EffortPickerField.tsx @@ -1,7 +1,3 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; -import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents"; import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types"; import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions"; import { @@ -11,40 +7,42 @@ import { import { PersonaDropdownField } from "./PersonaDropdownField"; /** - * Thinking-effort write control for the edit dialog (B5, v4 direct-write). + * Thinking-effort write control for the edit dialog. * - * Local-only by construction: the write calls `persistAgentEffortLevel`, which - * the Rust command rejects for non-local backends (remote effort is set at - * deploy time via `policy_env`). So the control renders only for a local - * backend AND once the adapter has advertised a `thought_level` configId - * (discovered from the running session — absent pre-first-session and for - * runtimes/models without effort support). The read-only configured-vs-running - * two-facts display lives in `AgentConfigPanel`; this is the write control. + * Local-only by construction: the persisted value flows into + * `persistAgentEffortLevel`, which the Rust command rejects for non-local + * backends (remote effort is set at deploy time via `policy_env`). So the + * control renders only for a local backend AND once the adapter has advertised + * a `thought_level` configId (discovered from the running session — absent + * pre-first-session and for runtimes/models without effort support). The + * read-only configured-vs-running two-facts display lives in `AgentConfigPanel`; + * this is the write control. * - * Direct-write: each selection persists immediately and invalidates the config - * surface so the panel's canonical tier reflects the new next-spawn value. + * Save-gated, not direct-write: the control is fully controlled by the parent + * dialog (`value`/`onChange`) and owns no mutation. The dialog persists the + * selection through a standalone setter on Save alone (mirroring + * `setManagedAgentAutoRestart`), so the write can never race the dialog's own + * locked `update_managed_agent` save or survive a Cancel/failed Save. */ export function EffortPickerField({ agent, config, + disabled, + value, + onChange, }: { agent: ManagedAgent; config: RuntimeConfigSurface | undefined; + disabled: boolean; + /** The pending persisted effort form (`null` = adapter default). */ + value: string | null; + onChange: (level: string | null) => void; }) { - const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: (level: string | null) => - persistAgentEffortLevel(agent.pubkey, level), - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: agentConfigSurfaceQueryKey(agent.pubkey), - }), - }); const { visible, options, selectValue } = effortPickerState({ backend: agent.backend, effortConfigId: config?.effortConfigId, effortOptions: config?.effortOptions, - currentEffort: config?.normalized.thinkingEffort?.value ?? null, + currentEffort: value, }); if (!visible) { @@ -61,10 +59,10 @@ export function EffortPickerField({ Optional - mutation.mutate(effortSelectionToPersistedValue(value)) + onValueChange={(next) => + onChange(effortSelectionToPersistedValue(next)) } options={options} placeholder="Adapter default" @@ -73,9 +71,6 @@ export function EffortPickerField({

Applied at the next session start.

- {mutation.error instanceof Error ? ( -

{mutation.error.message}

- ) : null} ); } diff --git a/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs index a6d4330b094..34b6e70bcfa 100644 --- a/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs +++ b/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs @@ -9,6 +9,7 @@ import { computeEditAgentFormValidity, hasMissingRequiredEnvKey, resolveAgentCommandUpdate, + resolveEffortSubmission, resolveInheritedRuntimeSubmission, resolveRuntimeProviderCapability, } from "./personaRuntimeModel.ts"; @@ -332,3 +333,72 @@ test("inheritToggle_cancelled_emitsNoUpdate", () => { "Save on the pin→inherit transition emits the empty-command sentinel the backend clears the column on", ); }); + +// ── Effort write is Save-gated and cannot re-pin an inherit transition ──────── +// +// Carl r8 P1. The effort picker no longer direct-writes on selection; the +// dialog holds the pending value and persists it on Save via a standalone +// setter (`resolveEffortSubmission` + `persistAgentEffortLevel`) sequenced +// AFTER the locked `update_managed_agent`. Two invariants this pins: +// 1. On the pin→inherit transition (agentCommandUpdate === ""), the locked +// save already cleared the effort column + aliases, so the effort setter +// must be SUPPRESSED — re-persisting the picked value would restore the +// very pin the transition just cleared (the deferred-IPC race). +// 2. Cancel never reaches the submit path, so no effort write is dispatched. + +test("inheritTransition_suppressesEffortWrite_evenWhenUserPickedAValue", () => { + // User pinned to Claude, selected effort "high", then switched to Inherit and + // saved. The command update is the inherit sentinel, so the effort write must + // be suppressed regardless of the picked value. + const agentCommandUpdate = resolveAgentCommandUpdate({ + inheritHarness: true, + agentCommand: pinnedAgent.agentCommand, + originalAgentCommand: pinnedAgent.agentCommand, + agentCommandOverride: pinnedAgent.agentCommandOverride, + }); + assert.equal(agentCommandUpdate, ""); + + const submission = resolveEffortSubmission({ + effortLevel: "high", // the deferred selection that used to race the save + originalEffortLevel: null, + inheritTransition: agentCommandUpdate === "", + }); + assert.equal( + submission.persist, + false, + "the pin→inherit transition must suppress the effort write so it cannot restore the just-cleared pin", + ); +}); + +test("effortWrite_persistsRealChange_onNonInheritSave", () => { + // A plain effort edit with no harness change: the setter persists the new + // value (agentCommandUpdate is undefined → not an inherit transition). + const submission = resolveEffortSubmission({ + effortLevel: "high", + originalEffortLevel: null, + inheritTransition: false, + }); + assert.deepEqual(submission, { persist: true, level: "high" }); +}); + +test("effortWrite_noOp_whenSelectionUnchanged", () => { + // Re-selecting the currently-effective value (or a name-only Save) writes + // nothing, so an unrelated edit never rewrites the effort column. + const submission = resolveEffortSubmission({ + effortLevel: "high", + originalEffortLevel: "high", + inheritTransition: false, + }); + assert.equal(submission.persist, false); +}); + +test("effortWrite_clearToAdapterDefault_persistsNull", () => { + // Clearing an existing pin to the adapter-default sentinel is a real change + // and must persist null (revert), not be treated as a no-op. + const submission = resolveEffortSubmission({ + effortLevel: null, + originalEffortLevel: "high", + inheritTransition: false, + }); + assert.deepEqual(submission, { persist: true, level: null }); +}); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index 20d789c4ebd..ab618f63ee2 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -90,6 +90,38 @@ export function resolveAgentCommandUpdate(input: { return undefined; } +/** + * Decide whether Save should persist the effort picker's selection, and with + * what value. The effort write is a Save-gated standalone setter (mirrors + * {@link resolveAgentCommandUpdate}'s Save-only sentinel and the + * `setManagedAgentAutoRestart` precedent), so this runs only inside the submit + * path — Cancel and a failed Save never reach it, and no write is dispatched + * independently of the dialog outcome. + * + * `inheritTransition` is the pin→inherit case (the empty-command sentinel + * `resolveAgentCommandUpdate` returns). The locked `update_managed_agent` save + * already clears the record effort column AND its env aliases on that + * transition; re-persisting the picker value here would restore the very pin + * the transition just cleared — the r8 race. So the transition suppresses the + * effort write entirely, regardless of the picked value. + * + * Otherwise persist only a real change: an unchanged selection is a no-op, so + * a name-only edit never rewrites the effort column. + */ +export function resolveEffortSubmission(input: { + effortLevel: string | null; + originalEffortLevel: string | null; + inheritTransition: boolean; +}): { persist: boolean; level: string | null } { + if ( + input.inheritTransition || + input.effortLevel === input.originalEffortLevel + ) { + return { persist: false, level: null }; + } + return { persist: true, level: input.effortLevel }; +} + /** * Whether any of the runtime/provider-required credential keys is unset. * From 06ce8784baae6cce1e2ae9d88a9f4bce48ccdd68 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 14:54:25 -0400 Subject: [PATCH 15/33] test(desktop): pin effort write to Save via dialog wiring regression The pure resolveEffortSubmission unit tests never invoke handleSubmit, so they could stay green if the picker regained a selection-time IPC or the setter jumped ahead of the locked update. Add a wiring-level regression that mounts the real dialog, drives the effort dropdown, and asserts the persist_agent_effort_level IPC boundary against controlled deferred update_managed_agent promises: selection alone and Cancel dispatch none, a failed update aborts before the setter, the setter fires only after the update resolves, and the pin->inherit transition suppresses it entirely. Align the governing docs to the implemented Save-gated, parent-owned contract: rule 14 and the effortPicker/runtimeModelProviderSelection comments no longer describe a direct-write control that owns its own mutation. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/AGENTS.md | 9 +- .../ui/agentInstanceEditCancelSafety.test.mjs | 239 +++++++++++++++++- .../src/features/agents/ui/effortPicker.ts | 14 +- .../ui/runtimeModelProviderSelection.test.mjs | 4 +- .../ui/runtimeModelProviderSelection.ts | 7 +- 5 files changed, 255 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 0478b6e05e1..bd916c46f93 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -223,11 +223,10 @@ with a TypeScript lookup table or an id comparison in a component. has been discovered from the running session (absent pre-first-session and for runtimes/models without effort support). Local-only is load-bearing, not cosmetic — the Rust command rejects non-local backends because remote effort - is set at deploy time via `policy_env`. Because it reads its inputs from the - config surface the dialog already fetches (`useAgentConfigSurface`) and owns - its own mutation, it does **not** thread new props through the over-1000-line - dialog (see rule 11): keep effort state inside the section component, never - as dialog-level props. The read-only display is the `thinkingEffort` + is set at deploy time via `policy_env`. The field owns no mutation: the dialog + holds the pending selection and threads it as `value`/`onChange` props, and + the single write lives in `handleSubmit` (see the Save-gated setter above), so + there is exactly one effort write path and it is gated on Save. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which already shows both facts — `field.value` (canonical: the effort the next spawn will launch with, projected to the runtime's native key) and, when a diff --git a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs index a8338a7ee91..fb28b8f670e 100644 --- a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs +++ b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs @@ -1,5 +1,5 @@ /** - * Cancel-safety acceptance pin (production seam). + * Cancel-safety + Save-gated effort acceptance pins (production seam). * * The pin→inherit effort/runtime clear is derived entirely inside the backend's * locked save, keyed off the `agentCommand: ""` sentinel that @@ -19,6 +19,17 @@ * so rewiring Cancel to handleSubmit() makes it fail. The companion test clicks * the REAL Save button and asserts the same boundary receives exactly one call * carrying the `agentCommand: ""` inherit sentinel. + * + * The effort-write tests (Carl r8 P1) pin the SUBMIT wiring the pure + * `resolveEffortSubmission` unit tests cannot reach: they mount the dialog with + * an effort-capable config surface, drive the REAL effort dropdown, and assert + * the `persist_agent_effort_level` IPC boundary against controlled deferred + * `update_managed_agent` promises. Selection alone and Cancel dispatch no effort + * call (a selection-time write would fail these); a failed main update dispatches + * none; the pin→inherit Save resolves the locked update and still dispatches no + * effort setter (dropping the inherit-transition guard fails it); and an ordinary + * effort Save proves the setter fires only AFTER `update_managed_agent` resolves + * (moving the write before the await fails the ordering assertion). */ import assert from "node:assert/strict"; @@ -211,6 +222,53 @@ function installIpc() { }); } +// An effort-capable local config surface: the picker renders only when the +// backend is local AND the running session has advertised a `thought_level` +// configId, so these must be present for the effort dropdown to mount. +function effortConfigSurface() { + return { + ...configSurface(), + effortConfigId: "thought_level", + effortOptions: [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, + ], + }; +} + +// Installs the effort-capable surface plus a CONTROLLABLE update boundary: the +// caller decides when `update_managed_agent` resolves (or rejects) so the tests +// can prove the effort setter fires strictly AFTER the locked update — never on +// selection, never before the update settles, never on a failed update. Returns +// `resolveUpdate` / `rejectUpdate` to settle the deferred update on demand. +function installEffortIpc({ deferUpdate = false, failUpdate = false } = {}) { + installIpc(); + const set = (cmd, handler) => ipcHandlers.set(cmd, handler); + set("get_agent_config_surface", () => Promise.resolve(effortConfigSurface())); + + let resolveUpdate = () => {}; + set("update_managed_agent", (args) => { + ipcCalls.push({ cmd: "update_managed_agent", args }); + if (failUpdate) { + return Promise.reject(new Error("update failed")); + } + const response = { agent: rawAgent(), profile_sync_error: null }; + if (!deferUpdate) { + return Promise.resolve(response); + } + return new Promise((resolve) => { + resolveUpdate = () => resolve(response); + }); + }); + set("persist_agent_effort_level", (args) => { + ipcCalls.push({ cmd: "persist_agent_effort_level", args }); + return Promise.resolve(); + }); + return { + resolveUpdate: () => resolveUpdate(), + }; +} + function renderDialog(onOpenChange) { const client = new QueryClient({ defaultOptions: { @@ -430,3 +488,182 @@ test("inherit toggle then Save dispatches the agentCommand:'' inherit sentinel", "Save on the pin→inherit transition must carry the empty-command sentinel the backend clears the column on", ); }); + +// ── Effort write is Save-gated: real dialog wiring, controlled deferred IPC ──── + +// Opens the effort dropdown (Radix DropdownMenu trigger) and selects the option +// whose visible label matches `label`. Mirrors a real user pick — the seam the +// pure resolveEffortSubmission unit tests never touch. +async function selectEffort(label) { + const trigger = dom.window.document.getElementById("edit-agent-effort"); + assert.ok( + trigger, + "effort picker trigger must render for a local + effort-capable agent", + ); + await act(async () => { + fireEvent.pointerDown( + trigger, + new dom.window.MouseEvent("pointerdown", { bubbles: true, button: 0 }), + ); + fireEvent.click(trigger); + }); + const item = [ + ...dom.window.document.querySelectorAll('[role="menuitemradio"]'), + ].find((node) => node.textContent?.trim() === label); + assert.ok(item, `effort option "${label}" must be offered`); + await act(async () => { + fireEvent.click(item); + }); +} + +function effortCalls() { + return ipcCalls.filter((c) => c.cmd === "persist_agent_effort_level"); +} + +test("effort selection alone dispatches no persist_agent_effort_level", async () => { + installEffortIpc(); + await act(async () => { + renderDialog(() => {}); + }); + + await selectEffort("High"); + + assert.equal( + effortCalls().length, + 0, + "picking an effort value must not write until Save — a selection-time IPC is the r8 race", + ); +}); + +test("effort selected then Cancel dispatches no persist_agent_effort_level", async () => { + installEffortIpc(); + let openChange; + await act(async () => { + renderDialog((next) => { + openChange = next; + }); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + }); + + assert.equal( + openChange, + false, + "Cancel must route through onOpenChange(false)", + ); + assert.equal( + effortCalls().length, + 0, + "Cancel after selecting an effort must discard the pending write", + ); +}); + +test("effort Save with a rejected update dispatches no persist_agent_effort_level", async () => { + installEffortIpc({ failUpdate: true }); + await act(async () => { + renderDialog(() => {}); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + }); + + assert.equal( + ipcCalls.filter((c) => c.cmd === "update_managed_agent").length, + 1, + "Save must attempt the locked update", + ); + assert.equal( + effortCalls().length, + 0, + "a failed update_managed_agent must abort before the sequenced effort setter", + ); +}); + +test("effort Save fires persist_agent_effort_level only AFTER update_managed_agent resolves", async () => { + const { resolveUpdate } = installEffortIpc({ deferUpdate: true }); + await act(async () => { + renderDialog(() => {}); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + }); + + // The locked update is still pending: the effort setter must not have fired, + // proving the write is sequenced strictly after the awaited update. + assert.equal( + ipcCalls.filter((c) => c.cmd === "update_managed_agent").length, + 1, + "the locked update is dispatched", + ); + assert.equal( + effortCalls().length, + 0, + "the effort setter must not fire while the locked update is still pending", + ); + + await act(async () => { + resolveUpdate(); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + const effort = effortCalls(); + assert.equal( + effort.length, + 1, + "the effort setter fires exactly once after the update resolves", + ); + assert.equal( + effort[0].args.effortLevel, + "high", + "the persisted value is the picked effort level", + ); + // Ordering: the update was recorded before the effort write in the same + // call log, so a setter moved ahead of the await would fail this. + const updateIndex = ipcCalls.findIndex( + (c) => c.cmd === "update_managed_agent", + ); + const effortIndex = ipcCalls.findIndex( + (c) => c.cmd === "persist_agent_effort_level", + ); + assert.ok( + updateIndex >= 0 && effortIndex > updateIndex, + "persist_agent_effort_level must be dispatched after update_managed_agent", + ); +}); + +test("pin→inherit Save with a picked effort dispatches no persist_agent_effort_level", async () => { + installEffortIpc(); + await act(async () => { + renderDialog(() => {}); + }); + + await selectEffort("High"); + await expandAdvancedAndToggleInherit(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + }); + + const updates = ipcCalls.filter((c) => c.cmd === "update_managed_agent"); + assert.equal( + updates.length, + 1, + "the pin→inherit Save dispatches the locked update", + ); + assert.equal( + updates[0].args.input.agentCommand, + "", + "the pin→inherit transition carries the clear sentinel", + ); + assert.equal( + effortCalls().length, + 0, + "the inherit-transition guard must suppress the effort write so it cannot restore the just-cleared pin — dropping the guard fails this", + ); +}); diff --git a/desktop/src/features/agents/ui/effortPicker.ts b/desktop/src/features/agents/ui/effortPicker.ts index 515355e4ad7..a0e409d300c 100644 --- a/desktop/src/features/agents/ui/effortPicker.ts +++ b/desktop/src/features/agents/ui/effortPicker.ts @@ -13,13 +13,13 @@ export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__"; /** * Pure gating + option compute for the effort write control in the edit dialog. * - * The picker is a LOCAL-only, direct-write control: it calls - * `persistAgentEffortLevel`, which the Rust command rejects for non-local - * backends (remote effort is set at deploy time via `policy_env`). So the UI - * must not offer it for a provider backend, and there's nothing to pick until - * the adapter has advertised a `thought_level` config option (discovered from - * the running session — `effortConfigId` is absent pre-first-session and for - * runtimes/models that don't support effort). + * The picker is a LOCAL-only, Save-gated write control: the dialog persists the + * selection via `persistAgentEffortLevel`, which the Rust command rejects for + * non-local backends (remote effort is set at deploy time via `policy_env`). So + * the UI must not offer it for a provider backend, and there's nothing to pick + * until the adapter has advertised a `thought_level` config option (discovered + * from the running session — `effortConfigId` is absent pre-first-session and + * for runtimes/models that don't support effort). * * `visible` is the single gate the dialog renders on: local backend AND a * discovered `effortConfigId`. diff --git a/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs b/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs index 1b1f0a6a7d9..84439ee7619 100644 --- a/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs +++ b/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs @@ -17,10 +17,10 @@ const base = { // --- selectionOnRuntimeChange --- -test("runtime switch clears stale effort env aliases (native + ACP sentinel), preserving the direct-write column", () => { +test("runtime switch clears stale effort env aliases (native + ACP sentinel), preserving the Save-gated column", () => { // Claude/buzz-agent → Goose: the previous runtime's effort env aliases are // stale under Goose. They are cleared; unrelated env survives. The canonical - // effort column is direct-write, not in this state, so it is untouched here. + // effort column is Save-gated, not in this state, so it is untouched here. const next = selectionOnRuntimeChange( { ...base, diff --git a/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts b/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts index fe13e5d90a1..4e4a375bbd8 100644 --- a/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts +++ b/desktop/src/features/agents/ui/runtimeModelProviderSelection.ts @@ -29,8 +29,9 @@ import { * * On a runtime switch these aliases become stale — they express the *previous* * runtime's vocabulary — so they are cleared. The canonical persisted effort - * (`record.effort_level`) is a direct-write column owned by `EffortPickerField` - * (AGENTS.md rule 14), lives outside this env-state selection, and is therefore + * (`record.effort_level`) is a Save-gated column persisted by + * `AgentInstanceEditDialog` (AGENTS.md rule 14), lives outside this env-state + * selection, and is therefore * PRESERVED across the switch: the launch projection normalizes it (or skips it * as absent) for the destination runtime, and switching back restores it. */ @@ -66,7 +67,7 @@ export function selectionOnRuntimeChange( // F3 nondestructive switch policy: clear the previous runtime's stale // thinking-effort env aliases (all native keys + the ACP sentinel). The - // canonical `record.effort_level` column is direct-write and not part of this + // canonical `record.effort_level` column is Save-gated and not part of this // selection state, so it is preserved — the launch projection re-expresses it // for the destination runtime, and switching back restores the preference. if (params.previousRuntime !== params.nextRuntime) { From 505bfa5126fa4a526a3b6f5d8fced0c7c40d6b31 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 15:10:35 -0400 Subject: [PATCH 16/33] test(desktop): fix installEffortIpc doc to match its return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper comment promised a `rejectUpdate` control it never returns — rejection is selected up front via `failUpdate`. Describe the actual `failUpdate`/`deferUpdate` inputs and the single `resolveUpdate` return. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/agentInstanceEditCancelSafety.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs index fb28b8f670e..ec9a838941f 100644 --- a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs +++ b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs @@ -237,10 +237,10 @@ function effortConfigSurface() { } // Installs the effort-capable surface plus a CONTROLLABLE update boundary: the -// caller decides when `update_managed_agent` resolves (or rejects) so the tests -// can prove the effort setter fires strictly AFTER the locked update — never on -// selection, never before the update settles, never on a failed update. Returns -// `resolveUpdate` / `rejectUpdate` to settle the deferred update on demand. +// tests can prove the effort setter fires strictly AFTER the locked update — +// never on selection, never before the update settles, never on a failed +// update. `failUpdate` makes `update_managed_agent` reject immediately; +// `deferUpdate` holds it pending until the returned `resolveUpdate()` is called. function installEffortIpc({ deferUpdate = false, failUpdate = false } = {}) { installIpc(); const set = (cmd, handler) => ipcHandlers.set(cmd, handler); From 8e44644788d3d38f88d142cbf3393e30b7b6796f Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 18:49:58 -0400 Subject: [PATCH 17/33] fix(desktop): gate dialog on the full Save sequence, not just update mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pending/disabled control in AgentInstanceEditDialog keyed off updateMutation.isPending alone. handleSubmit awaits two standalone setters (setManagedAgentAutoRestart, persistAgentEffortLevel) after the update resolves, so once mutateAsync settled the dialog re-enabled — Cancel, duplicate Save, and all fields became interactive while those setters were still in flight. A Cancel during that window let an in-flight effort setter commit; a duplicate Save raced duplicate standalone writes; a setter failure was silently swallowed. Introduce isSaving state that spans the complete Save sequence and replace the ten updateMutation.isPending gate sites with it. Wrap the standalone setters in a nested try/catch that sets setterError on failure and returns before handleOpenChange, keeping the dialog open for retry. The outer finally always clears isSaving. displayError surfaces whichever error fired (setter error takes precedence over a stale update error since the update already committed). Extend agentInstanceEditCancelSafety.test.mjs with four new regression tests that fail against the old isPending-only gate: Cancel and Save disabled during the locked update; Cancel and Save disabled during the effort setter; duplicate Save dispatches exactly one update; setter rejection renders a retryable error with dialog open. Mutation evidence: reverting the isSaving gate back to updateMutation.isPending fails the three button-state tests immediately. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/AgentInstanceEditDialog.tsx | 113 ++++++---- .../ui/agentInstanceEditCancelSafety.test.mjs | 193 +++++++++++++++++- 2 files changed, 261 insertions(+), 45 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 1a7da3fea83..a1510d90f14 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -119,6 +119,13 @@ export function AgentInstanceEditDialog({ const updateMutation = useUpdateManagedAgentMutation(); const startMutation = useStartManagedAgentMutation(); const queryClient = useQueryClient(); + // Spans the COMPLETE Save sequence: locked update + standalone setters. Every + // pending/disabled gate must key off this, not updateMutation.isPending alone, + // so the dialog stays gated until all persistence steps settle (Carl r9 P1). + const [isSaving, setIsSaving] = React.useState(false); + // Surfaces a standalone-setter failure (auto-restart or effort) that React + // Query does not track — keeps the dialog open so the user can retry Save. + const [setterError, setSetterError] = React.useState(null); const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; @@ -207,6 +214,7 @@ export function AgentInstanceEditDialog({ setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); setEffortLevel(null); effortTouched.current = false; + setSetterError(null); setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); setAvatarUrl(agent.avatarUrl ?? ""); @@ -625,10 +633,12 @@ export function AgentInstanceEditDialog({ requiredEnvKeyMissing, }) && providerValid && - !updateMutation.isPending && + !isSaving && !isAvatarUploadPending; async function handleSubmit() { + setIsSaving(true); + setSetterError(null); try { const parsedParallelism = Number.parseInt(parallelism, 10); const parsedArgs = agentArgs @@ -738,36 +748,44 @@ export function AgentInstanceEditDialog({ }; const result = await updateMutation.mutateAsync(input); - if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { - // Standalone setter (mirrors start-on-app-launch) — not part of - // UpdateManagedAgentInput, so the frozen update shape stays frozen. - await setManagedAgentAutoRestart( - agent.pubkey, - autoRestartOnConfigChange, - ); - } - // Effort is a Save-gated standalone setter, sequenced AFTER the update - // resolves. Folding it into the frozen UpdateManagedAgentInput is - // avoided; sequencing after the locked save is what removes the r8 race - // (a selection can no longer land its own IPC between here and Save). The - // pin→inherit transition (agentCommandUpdate === "") already cleared the - // effort column + aliases inside that locked save, so resolveEffortSubmission - // suppresses the write there — re-persisting would restore the just-cleared pin. - const effortSubmission = resolveEffortSubmission({ - effortLevel, - originalEffortLevel: - configSurfaceQuery.data?.normalized.thinkingEffort?.value ?? null, - inheritTransition: agentCommandUpdate === "", - }); - if (effortTouched.current && effortSubmission.persist) { - await persistAgentEffortLevel(agent.pubkey, effortSubmission.level); - // The picker owns no mutation now, so refresh the config surface here - // (what its own onSuccess used to do) — the panel's canonical tier must - // reflect the new next-spawn value. - await queryClient.invalidateQueries({ - queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + + // Standalone setters — sequenced after the locked update resolves so the + // dialog remains fully gated (isSaving) for the COMPLETE Save transaction. + // A failure here surfaces as setterError (retryable) and aborts before + // close, so a setter rejection cannot silently go unnoticed (Carl r9 P1). + try { + if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { + // Mirrors start-on-app-launch; not part of UpdateManagedAgentInput so + // the frozen update shape stays frozen. + await setManagedAgentAutoRestart( + agent.pubkey, + autoRestartOnConfigChange, + ); + } + // Effort is a Save-gated standalone setter, sequenced AFTER the update + // resolves. The pin→inherit transition (agentCommandUpdate === "") already + // cleared effort inside the locked save, so resolveEffortSubmission + // suppresses the write there — re-persisting would restore the cleared pin. + const effortSubmission = resolveEffortSubmission({ + effortLevel, + originalEffortLevel: + configSurfaceQuery.data?.normalized.thinkingEffort?.value ?? null, + inheritTransition: agentCommandUpdate === "", }); + if (effortTouched.current && effortSubmission.persist) { + await persistAgentEffortLevel(agent.pubkey, effortSubmission.level); + // The picker owns no mutation now, so refresh the config surface here + // (what its own onSuccess used to do) — the panel's canonical tier must + // reflect the new next-spawn value. + await queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }); + } + } catch (e) { + setSetterError(e instanceof Error ? e : new Error("Failed to save")); + return; } + showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); handleOpenChange(false); onUpdated?.(result.agent); @@ -795,7 +813,9 @@ export function AgentInstanceEditDialog({ }); } } catch { - // React Query stores the error; keep dialog open and render it inline. + // React Query stores the update error; keep dialog open and render it inline. + } finally { + setIsSaving(false); } } @@ -878,6 +898,11 @@ export function AgentInstanceEditDialog({ const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } : ADVANCED_FIELDS_MOTION_TRANSITION; + // Displayed inline when either the locked update or a standalone setter fails. + // setterError takes precedence — the update already committed when it fires. + const displayError = + setterError ?? + (updateMutation.error instanceof Error ? updateMutation.error : null); return ( @@ -891,7 +916,7 @@ export function AgentInstanceEditDialog({ footer={
} @@ -960,7 +985,7 @@ export function AgentInstanceEditDialog({ "h-8 px-0 py-0 leading-6", PERSONA_FIELD_CONTROL_CLASS, )} - disabled={updateMutation.isPending} + disabled={isSaving} id="edit-agent-name" onChange={(event) => setName(event.target.value)} placeholder="Agent name" @@ -971,7 +996,7 @@ export function AgentInstanceEditDialog({ setAgentCommand(event.target.value)} placeholder="Full path or shell command" @@ -1041,7 +1066,7 @@ export function AgentInstanceEditDialog({ ) : null} {/* LLM provider + provider API key + model */} - {/* Error */} - {updateMutation.error instanceof Error ? ( -

- {updateMutation.error.message} -

+ {/* Error — covers both the locked update (React Query) and the + standalone setters (setterError); setter error takes precedence + since the update already committed when it fires. */} + {displayError != null ? ( +

{displayError.message}

) : null} diff --git a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs index ec9a838941f..fa6885f8d53 100644 --- a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs +++ b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs @@ -220,6 +220,13 @@ function installIpc() { ipcCalls.push({ cmd: "update_managed_agent", args }); return Promise.resolve({ agent: rawAgent(), profile_sync_error: null }); }); + // No-op: the auto-restart setter is a standalone IPC that existing tests + // never trigger (agent state matches dialog default), but mock it so a + // test that flips autoRestartOnConfigChange doesn't hit "unmocked command". + set("set_managed_agent_auto_restart", (args) => { + ipcCalls.push({ cmd: "set_managed_agent_auto_restart", args }); + return Promise.resolve(); + }); } // An effort-capable local config surface: the picker renders only when the @@ -241,7 +248,13 @@ function effortConfigSurface() { // never on selection, never before the update settles, never on a failed // update. `failUpdate` makes `update_managed_agent` reject immediately; // `deferUpdate` holds it pending until the returned `resolveUpdate()` is called. -function installEffortIpc({ deferUpdate = false, failUpdate = false } = {}) { +// `failSetter` makes `persist_agent_effort_level` reject so the new setterError +// surface and retryable-dialog behavior can be verified. +function installEffortIpc({ + deferUpdate = false, + failUpdate = false, + failSetter = false, +} = {}) { installIpc(); const set = (cmd, handler) => ipcHandlers.set(cmd, handler); set("get_agent_config_surface", () => Promise.resolve(effortConfigSurface())); @@ -262,6 +275,9 @@ function installEffortIpc({ deferUpdate = false, failUpdate = false } = {}) { }); set("persist_agent_effort_level", (args) => { ipcCalls.push({ cmd: "persist_agent_effort_level", args }); + if (failSetter) { + return Promise.reject(new Error("effort save failed")); + } return Promise.resolve(); }); return { @@ -667,3 +683,178 @@ test("pin→inherit Save with a picked effort dispatches no persist_agent_effort "the inherit-transition guard must suppress the effort write so it cannot restore the just-cleared pin — dropping the guard fails this", ); }); + +// ── Composite isSaving gate covers the FULL Save transaction (Carl r9 P1) ──── + +test("Cancel and Save are disabled while the locked update is in flight", async () => { + const { resolveUpdate } = installEffortIpc({ deferUpdate: true }); + let openChange; + await act(async () => { + renderDialog((next) => { + openChange = next; + }); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + }); + + // While update_managed_agent is still pending, both buttons must be gated. + const cancelBtn = screen.getByRole("button", { name: "Cancel" }); + const saveBtn = screen.getByRole("button", { name: "Saving..." }); + assert.ok( + cancelBtn.disabled, + "Cancel must be disabled while the locked update is pending — keying off updateMutation.isPending alone would leave it open to a window-close race", + ); + assert.ok( + saveBtn.disabled, + "Save must remain disabled (Saving... label) while the locked update is pending", + ); + assert.equal(openChange, undefined, "dialog must not have closed yet"); + + // Resolve and confirm the dialog eventually closes normally. + await act(async () => { + resolveUpdate(); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + assert.equal( + openChange, + false, + "dialog closes after the full Save sequence completes", + ); +}); + +test("Cancel and Save are disabled while the effort setter is in flight", async () => { + // Defer the effort setter itself by intercepting after the update resolves. + installIpc(); + const set = (cmd, handler) => ipcHandlers.set(cmd, handler); + set("get_agent_config_surface", () => Promise.resolve(effortConfigSurface())); + set("update_managed_agent", (args) => { + ipcCalls.push({ cmd: "update_managed_agent", args }); + return Promise.resolve({ agent: rawAgent(), profile_sync_error: null }); + }); + let resolveEffort = () => {}; + set("persist_agent_effort_level", (args) => { + ipcCalls.push({ cmd: "persist_agent_effort_level", args }); + return new Promise((resolve) => { + resolveEffort = () => resolve(); + }); + }); + + let openChange; + await act(async () => { + renderDialog((next) => { + openChange = next; + }); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + // update_managed_agent has resolved but persist_agent_effort_level is still + // pending — the dialog must stay gated the whole time (isSaving still true). + const cancelBtn = screen.getByRole("button", { name: "Cancel" }); + const saveBtn = screen.getByRole("button", { name: "Saving..." }); + assert.ok( + cancelBtn.disabled, + "Cancel must remain disabled until the effort setter resolves — a gate keyed only on updateMutation.isPending would re-enable it here", + ); + assert.ok( + saveBtn.disabled, + "Save must remain disabled (Saving...) until the effort setter resolves", + ); + assert.equal(openChange, undefined, "dialog must not have closed yet"); + + await act(async () => { + resolveEffort(); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + assert.equal( + openChange, + false, + "dialog closes after the effort setter resolves", + ); +}); + +test("duplicate Save is impossible while a Save is in flight", async () => { + const { resolveUpdate } = installEffortIpc({ deferUpdate: true }); + await act(async () => { + renderDialog(() => {}); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + }); + + // Attempt a second Save while the first is pending. + const saveBtn = screen.getByRole("button", { name: "Saving..." }); + await act(async () => { + fireEvent.click(saveBtn); + }); + + assert.equal( + ipcCalls.filter((c) => c.cmd === "update_managed_agent").length, + 1, + "exactly one update_managed_agent must have been dispatched — a duplicate submit races a duplicate standalone write", + ); + + await act(async () => { + resolveUpdate(); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); +}); + +test("effort setter rejection renders a retryable error with dialog still open", async () => { + installEffortIpc({ failSetter: true }); + let openChange; + await act(async () => { + renderDialog((next) => { + openChange = next; + }); + }); + + await selectEffort("High"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + // The update committed but the effort setter rejected. + assert.equal( + ipcCalls.filter((c) => c.cmd === "update_managed_agent").length, + 1, + "the locked update was dispatched", + ); + assert.equal( + effortCalls().length, + 1, + "persist_agent_effort_level was attempted", + ); + assert.equal( + openChange, + undefined, + "dialog must stay open on setter failure", + ); + + // A visible, non-empty error must be rendered so the user knows Save failed. + const errorEl = dom.window.document.querySelector(".text-destructive"); + assert.ok(errorEl, "a destructive-styled error must be rendered"); + assert.ok( + errorEl.textContent?.trim().length > 0, + "error element must have non-empty text", + ); + + // Save must be re-enabled so the user can retry (isSaving is false now). + const saveBtn = screen.getByRole("button", { name: "Save changes" }); + assert.ok( + !saveBtn.disabled, + "Save must be re-enabled after a setter failure so the user can retry", + ); +}); From f526ed303895b8605538d44aa554a5959746c3bd Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 19:24:28 -0400 Subject: [PATCH 18/33] =?UTF-8?q?fix(desktop):=20close=20dialog=20perimete?= =?UTF-8?q?r=20=E2=80=94=20guard=20Escape/close-X/checkboxes=20during=20Sa?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thufir r9 pass-1 found the isSaving core solid but the perimeter incomplete: five bypass paths still let users mutate state or dismiss during an in-flight Save, and EditAgentAdvancedFields didn't thread disabled down to its mutable controls (checkboxes, NumericTuningFields, BuzzAgentModelTuningFields). Changes: - handleOpenChange: add early return on !next && isSaving — Escape, overlay, and close-X all route through onOpenChange; success path calls onOpenChange(false) directly, bypassing the guard. No allowClose ref needed. - Edit avatar button: add disabled={isSaving} (onClick guard is now redundant since handleOpenChange rejects the dismiss, but disabled is the visible cue). - AgentAiDefaultsNotice.onEditDefaults: inline !isSaving guard — prop has no disabled API, so the guard is the minimal change. - Advanced disclosure: add disabled={isSaving} with pointer-events-none/opacity-50 classes (plain