From b75356d6e80276175c6c854b1933500ea5023b31 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 12 Jun 2026 15:16:41 -0400 Subject: [PATCH 01/14] feat(desktop): harness-agnostic config bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-tier config bridge that reads agent configuration from config files (goose YAML, claude JSON, codex TOML), ACP session data, env vars, and Sprout-explicit overrides — surfacing a unified normalized config surface to the desktop UI regardless of runtime. Key changes: - Config bridge module with per-runtime file readers - ACP session config caching for post-spawn config visibility - AgentConfigPanel component with origin badges and tier provenance - Serde internally-tagged enums matching TypeScript discriminated unions - TOCTOU-safe write path with single lock scope - E2E mock handler for get_agent_config_surface with per-runtime fixtures and a Playwright screenshot spec covering 7 scenarios - Info icon + tooltip on read-only fields; override/strikethrough rendering for superseded config values Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/pool.rs | 10 + desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 14 +- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/app_state.rs | 21 + .../src-tauri/src/commands/agent_config.rs | 295 +++++++++ desktop/src-tauri/src/commands/agents.rs | 72 ++- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 3 + .../config_bridge/buzz_agent.rs | 7 + .../managed_agents/config_bridge/claude.rs | 155 +++++ .../src/managed_agents/config_bridge/codex.rs | 170 +++++ .../src/managed_agents/config_bridge/goose.rs | 249 ++++++++ .../src/managed_agents/config_bridge/mod.rs | 9 + .../managed_agents/config_bridge/reader.rs | 582 ++++++++++++++++++ .../src/managed_agents/config_bridge/types.rs | 217 +++++++ .../managed_agents/config_bridge/writer.rs | 146 +++++ .../src-tauri/src/managed_agents/discovery.rs | 23 +- .../src/managed_agents/env_vars/tests.rs | 111 +++- desktop/src-tauri/src/managed_agents/mod.rs | 1 + desktop/src/features/agents/hooks.ts | 14 + .../src/features/agents/observerRelayStore.ts | 5 +- .../features/agents/ui/AgentConfigPanel.tsx | 309 ++++++++++ .../features/agents/ui/ManagedAgentRow.tsx | 10 + .../src/features/agents/ui/ModelPicker.tsx | 49 +- desktop/src/shared/api/tauri.ts | 26 + desktop/src/shared/api/types.ts | 95 +++ desktop/src/testing/e2eBridge.ts | 302 +++++++++ .../e2e/config-bridge-screenshots.spec.ts | 196 ++++++ 30 files changed, 3040 insertions(+), 56 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_config.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/claude.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/codex.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/goose.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/mod.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/reader.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/types.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/writer.rs create mode 100644 desktop/src/features/agents/ui/AgentConfigPanel.tsx create mode 100644 desktop/tests/e2e/config-bridge-screenshots.spec.ts diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6f82f2a24..3be7b75ef 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -464,6 +464,16 @@ async fn create_session_and_apply_model( }); } + // Emit session config for desktop consumption (config bridge tier 1b). + agent.acp.observe( + "session_config_captured", + serde_json::json!({ + "configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null), + "modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null), + "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null), + }), + ); + // Apply desired_model if set, matching against the fresh session/new response. if let Some(ref desired) = agent.desired_model { match resolve_model_switch_method(&resp.raw, desired) { diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c431eba7..d31d0e987 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ "**/active-turn-screenshots.spec.ts", "**/active-turn-resilience-screenshots.spec.ts", "**/profile-active-turn-screenshots.spec.ts", + "**/config-bridge-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/video-attachment.spec.ts", "**/spoiler.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 79cd0e664..8cf9da0b9 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,7 +34,7 @@ const overrides = new Map([ // read-time relay-URL workspace fallback while keeping the create-time env // pin (the credential-leak guard). Load-bearing feature growth from the // rebase, queued to split with the rest of this list. - ["src-tauri/src/commands/agents.rs", 1350], + ["src-tauri/src/commands/agents.rs", 1375], // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned // outside NEST_DIRS (it may be a symlink), so it needs its own create + // chmod-only-when-real-dir handling plus integration test coverage. The @@ -60,13 +60,13 @@ const overrides = new Map([ // harness-persona-sync `harnessOverride` create-input bit — load-bearing // parameter plumbing, not generic debt growth. Approved override; still // queued to split. - ["src/shared/api/tauri.ts", 1209], + ["src/shared/api/tauri.ts", 1235], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); // types.rs adds the persona/instance harness fields. Load-bearing, not // generic debt. - ["src-tauri/src/managed_agents/discovery.rs", 1043], + ["src-tauri/src/managed_agents/discovery.rs", 1064], ["src-tauri/src/managed_agents/types.rs", 1037], // migration_tests.rs carries the harness-sync migration coverage plus the // patch_json_records owner-only writeback regression test (SECURITY.md:90 @@ -80,12 +80,16 @@ const overrides = new Map([ // syncs team-dir edits before all personas.json readers; run_event_sync // signs the persona/team retention events post-identity) layered on top of // main's growth. Load-bearing feature growth, queued to split with the list. - ["src-tauri/src/lib.rs", 1026], + ["src-tauri/src/lib.rs", 1029], // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop // already here) for the single-toggle mark-read/unread menu item — a small // overage from load-bearing per-message plumbing, not generic debt growth. // Approved override; still queued to split with the rest of this list. ["src/features/messages/ui/MessageThreadPanel.tsx", 1006], + // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge + // panel — a small overage from load-bearing UI plumbing, not generic debt + // growth. Approved override; still queued to split with the rest of this list. + ["src/features/profile/ui/UserProfilePanelSections.tsx", 1002], // useDueReminderBadgeCount hook call + sum to wire due-reminder count into // the Inbox nav badge — a small overage from load-bearing badge plumbing, // not generic debt growth. Approved override; still queued to split. @@ -94,7 +98,7 @@ const overrides = new Map([ // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at // 893 lines, not generic debt growth. Approved override; still queued to split. - ["src-tauri/src/app_state.rs", 1012], + ["src-tauri/src/app_state.rs", 1033], ]); await runFileSizeCheck({ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 34180c95f..34b774f94 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "tokio-util", + "toml 0.8.2", "url", "uuid", "windows-sys 0.61.2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3aced7026..aa7a3a953 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +toml = "0.8" nostr = { version = "0.44", features = ["nip44"] } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index c63ecb2ff..cce67cf5b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -10,6 +10,7 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::ManagedAgentProcess; pub struct AppState { @@ -33,6 +34,9 @@ pub struct AppState { pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, + /// Cached ACP session config from running agents, keyed by agent pubkey. + /// Populated when the harness emits `session_config_captured` observer events. + pub session_config_cache: Mutex>, /// IOKit power assertion state — prevents idle sleep while agents run. pub prevent_sleep: Arc>, /// In-process mesh-llm node started by Buzz Desktop. @@ -92,6 +96,7 @@ pub fn build_app_state() -> AppState { managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), + session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), app_handle: Mutex::new(None), audio_output_device: Mutex::new(None), @@ -116,6 +121,22 @@ impl AppState { self.huddle_state.lock().map_err(|e| e.to_string()) } + pub fn get_session_cache(&self, pubkey: &str) -> Option { + self.session_config_cache.lock().ok()?.get(pubkey).cloned() + } + + pub fn put_session_cache(&self, pubkey: &str, cache: SessionConfigCache) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.insert(pubkey.to_string(), cache); + } + } + + pub fn clear_session_cache(&self, pubkey: &str) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.remove(pubkey); + } + } + /// Emit the current huddle state to the frontend via Tauri event. /// /// Acquires both locks (app_handle + huddle_state), clones a snapshot, diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs new file mode 100644 index 000000000..554a53672 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -0,0 +1,295 @@ +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + config_bridge::{ + reader::read_config_surface, + types::{ + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigWriteMechanism, + RuntimeConfigSurface, SessionConfigCache, WriteConfigFieldRequest, + WriteConfigResult, WriteConfigTarget, + }, + writer::plan_config_write, + }, + known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes, + }, +}; + +/// Get the full config surface for a managed agent. +/// +/// Returns normalized + advanced config from all available tiers. +/// Pre-spawn agents show config file values with ACP tiers marked as pending. +#[tauri::command] +pub async fn get_agent_config_surface( + pubkey: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let record = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + records + .into_iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))? + }; + + let runtime_meta = known_acp_runtime(&record.agent_command); + let session_cache = state.get_session_cache(&pubkey); + + Ok(read_config_surface( + &record, + runtime_meta, + session_cache.as_ref(), + )) +} + +/// Write a config field value for a managed agent. +/// +/// Plans the write mechanism based on the current config surface, then +/// executes: either updating the record (for env var respawn) or returning +/// the mechanism for the frontend to send via observer control (for ACP writes). +#[tauri::command] +pub async fn write_agent_config_field( + request: WriteConfigFieldRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + + let record = records + .iter() + .find(|r| r.pubkey == request.pubkey) + .cloned() + .ok_or_else(|| format!("agent {} not found", request.pubkey))?; + + let runtime_meta = known_acp_runtime(&record.agent_command); + let session_cache = state.get_session_cache(&request.pubkey); + let surface = read_config_surface(&record, runtime_meta, session_cache.as_ref()); + + let mut result = plan_config_write(&surface, &request.field); + + if !result.success { + return Ok(result); + } + + if let ConfigWriteMechanism::RespawnWithEnvVar { ref env_key } = result.mechanism_used { + let record = records + .iter_mut() + .find(|r| r.pubkey == request.pubkey) + .ok_or_else(|| format!("agent {} not found", request.pubkey))?; + + match request.value { + Some(ref val) if !val.is_empty() => { + record.env_vars.insert(env_key.clone(), val.clone()); + } + _ => { + record.env_vars.remove(env_key); + } + } + + if matches!(request.field, WriteConfigTarget::Model) { + record.model = request.value.clone(); + } + + record.updated_at = crate::util::now_iso(); + save_managed_agents(&app, &records)?; + result.requires_restart = true; + } + + Ok(result) +} + +/// Store a `session_config_captured` observer event payload into the session cache. +/// +/// Called by the TypeScript observer relay when it decrypts a `session_config_captured` +/// event from a running agent. The payload contains raw ACP session/new fields. +#[tauri::command] +pub fn put_agent_session_config( + pubkey: String, + payload: serde_json::Value, + app: AppHandle, + state: State<'_, AppState>, +) { + { + let _guard = match state.managed_agents_store_lock.lock() { + Ok(g) => g, + Err(_) => return, + }; + match load_managed_agents(&app) { + Ok(records) if records.iter().any(|r| r.pubkey == pubkey) => {} + _ => return, + } + } + + let config_options = parse_config_options(payload.get("configOptions")); + let available_modes = parse_modes(&config_options, payload.get("modes")); + let (available_models, current_model) = parse_models(payload.get("models")); + + let cache = SessionConfigCache { + config_options, + available_modes, + available_models, + current_model, + goose_native_config: None, + captured_at: crate::util::now_iso(), + }; + + state.put_session_cache(&pubkey, cache); +} + +fn parse_config_options(raw: Option<&serde_json::Value>) -> Vec { + let arr = match raw.and_then(|v| v.as_array()) { + Some(a) => a, + None => return Vec::new(), + }; + arr.iter() + .filter_map(|opt| { + let config_id = opt + .get("id") + .or_else(|| opt.get("configId"))? + .as_str()? + .to_string(); + Some(AcpConfigOptionEntry { + config_id, + category: opt + .get("category") + .and_then(|v| v.as_str()) + .map(str::to_string), + display_name: opt + .get("displayName") + .and_then(|v| v.as_str()) + .map(str::to_string), + current_value: opt + .get("value") + .or_else(|| opt.get("currentValue")) + .and_then(|v| v.as_str()) + .map(str::to_string), + options: parse_option_values(opt.get("options")), + }) + }) + .collect() +} + +fn parse_option_values(raw: Option<&serde_json::Value>) -> Vec { + let arr = match raw.and_then(|v| v.as_array()) { + Some(a) => a, + None => return Vec::new(), + }; + arr.iter() + .filter_map(|o| { + let value = o.get("value").and_then(|v| v.as_str())?.to_string(); + Some(AcpConfigOptionValue { + value, + display_name: o + .get("displayName") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + }) + .collect() +} + +fn parse_modes( + config_options: &[AcpConfigOptionEntry], + raw: Option<&serde_json::Value>, +) -> Vec { + if let Some(arr) = raw.and_then(|v| v.as_array()) { + return arr + .iter() + .filter_map(|m| m.as_str().map(str::to_string)) + .collect(); + } + // Fall back: extract mode options from configOptions with category "mode". + config_options + .iter() + .filter(|o| o.category.as_deref() == Some("mode")) + .flat_map(|o| o.options.iter().map(|v| v.value.clone())) + .collect() +} + +fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option) { + let raw = match raw { + Some(v) => v, + None => return (Vec::new(), None), + }; + + // Object shape: { currentModelId, availableModels: [...] } + if let Some(obj) = raw.as_object() { + let current_model = obj + .get("currentModelId") + .and_then(|v| v.as_str()) + .map(str::to_string); + let models = obj + .get("availableModels") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| { + let model_id = m + .get("modelId") + .or_else(|| m.get("id")) + .and_then(|v| v.as_str())? + .to_string(); + Some(AcpModelEntry { + model_id, + name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + }) + .collect() + }) + .unwrap_or_default(); + return (models, current_model); + } + + // Array shape: [{ modelId, isCurrent, ... }] + let arr = match raw.as_array() { + Some(a) => a, + None => return (Vec::new(), None), + }; + let mut current_model = None; + let models = arr + .iter() + .filter_map(|m| { + let model_id = m + .get("modelId") + .or_else(|| m.get("id")) + .and_then(|v| v.as_str())? + .to_string(); + if m.get("isCurrent") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + current_model = Some(model_id.clone()); + } + Some(AcpModelEntry { + model_id, + name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + }) + .collect(); + (models, current_model) +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 5f34e2e8f..250d3e023 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -8,12 +8,11 @@ use crate::{ find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, - save_managed_agents, spawn_key_refusal, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, + ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -280,21 +279,43 @@ async fn start_local_agent_with_preflight( /// `"private_key_nsec": ""` and launch the agent with no identity — the same /// hazard the local spawn path refuses via `spawn_key_refusal`. fn build_deploy_payload( + app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) -> Result { - if let Some(error) = spawn_key_refusal(record) { - return Err(error); - } - // The record's env_vars is the complete pinned env map (persona env merged - // under agent overrides at create). `merged_user_env` with an empty persona - // map applies the reserved-key / malformed-key / NUL filtering. Re-reading - // persona env live here would leak post-create credential edits into a - // pinned agent — the bug the create-time snapshot exists to prevent. - let merged_env = crate::managed_agents::merged_user_env( - &std::collections::BTreeMap::new(), - &record.env_vars, - ); + // Merge persona env_vars + agent env_vars for provider deploy. Same + // precedence as local spawn: persona first, agent overrides last. Without + // this, provider-backed agents wouldn't receive credentials saved on the + // persona or the agent itself. + let persona_env = + crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; + let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars); + + // Resolve the persona's structured provider/model so the remote provider + // receives the same authoritative values that local spawn derives from + // `runtime_metadata_env_vars`. Without this, remote deploy would rely on + // stale derived env copies in `env_vars` (or have no provider at all for + // imported personas whose derived keys were filtered at import time). + // + // Precedence mirrors local spawn: persona structured model is authoritative + // when present; the agent record's `model` is a fallback for personas that + // don't specify one (or when no persona is linked). + let (effective_model, effective_provider) = if let Some(pid) = record.persona_id.as_deref() { + let personas = load_personas(app).map_err(|e| { + format!( + "failed to load personas while building deploy payload for persona `{pid}`: {e}" + ) + })?; + let persona = personas + .into_iter() + .find(|p| p.id == pid) + .ok_or_else(|| format!("persona `{pid}` not found while building deploy payload"))?; + let model = persona.model.clone().or(record.model.clone()); + let provider = persona.provider; + (model, provider) + } else { + (record.model.clone(), None) + }; Ok(serde_json::json!({ "name": &record.name, @@ -312,8 +333,11 @@ fn build_deploy_payload( "agent_command": &record.agent_command, "agent_args": &record.agent_args, "system_prompt": &record.system_prompt, - "model": &record.model, - "provider": &record.provider, + "model": effective_model, + // Structured provider from the persona record. Providers that don't + // yet read this field will fall back to env_vars or their own default + // — no protocol break. + "provider": effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, @@ -831,7 +855,7 @@ pub async fn create_managed_agent( .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| "agent disappeared".to_string())?; - build_deploy_payload(&state, rec)? + build_deploy_payload(&app, &state, rec)? }; match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { Ok(()) => spawn_error, @@ -959,7 +983,7 @@ pub async fn start_managed_agent( StartTarget::Provider { backend: record.backend.clone(), cached_binary_path: record.provider_binary_path.clone(), - agent_json: build_deploy_payload(&state, record)?, + agent_json: build_deploy_payload(&app, &state, record)?, } }; @@ -1197,6 +1221,7 @@ pub fn stop_managed_agent( } stop_managed_agent_process(&app, record, &mut runtimes)?; } + state.clear_session_cache(&pubkey); save_managed_agents(&app, &records)?; let record = records .iter() @@ -1246,10 +1271,9 @@ pub fn delete_managed_agent( } if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - // For local agents: kills the process. For remote agents: no-op (the frontend - // sends !shutdown via WebSocket before calling delete). Either way, safe. stop_managed_agent_process(&app, record, &mut runtimes)?; } + state.clear_session_cache(&pubkey); let initial_len = records.len(); records.retain(|record| record.pubkey != pubkey); if records.len() == initial_len { diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a8bce1081..99686fee2 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_config; mod agent_discovery; mod agent_models; mod agent_settings; @@ -28,6 +29,7 @@ mod teams; mod workflows; mod workspace; +pub use agent_config::*; pub use agent_discovery::*; pub use agent_models::*; pub use agent_settings::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b713fce13..2da8a7053 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -818,6 +818,9 @@ pub fn run() { delete_managed_agent, get_managed_agent_log, get_agent_models, + get_agent_config_surface, + write_agent_config_field, + put_agent_session_config, mesh_availability, mesh_start_node, mesh_ensure_client_node, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs b/desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs new file mode 100644 index 000000000..e887db8a3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs @@ -0,0 +1,7 @@ +use super::types::RuntimeFileConfig; + +/// Buzz-agent has no config file — returns an empty config. +/// All config comes from env vars (tier 2a) set at spawn time. +pub(super) fn read_config_file() -> Option { + None +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs new file mode 100644 index 000000000..31278fa78 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -0,0 +1,155 @@ +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. +pub(super) fn read_config_file() -> Option { + let home = dirs::home_dir()?; + let settings_path = home.join(".claude").join("settings.json"); + let mcp_path = home.join(".claude.json"); + + let settings = read_json_file(&settings_path); + let mcp_config = read_json_file(&mcp_path); + + if settings.is_none() && mcp_config.is_none() { + return None; + } + + let mut cfg = RuntimeFileConfig::default(); + + if let Some(ref s) = settings { + cfg.model = json_string(s, "model"); + + if let Some(permissions) = s.get("permissions") { + if let Some(mode) = permissions.get("default").and_then(|v| v.as_str()) { + cfg.extra + .insert("permissions.default".to_string(), mode.to_string()); + } + } + + if s.get("hooks").is_some() { + cfg.extra + .insert("hooks".to_string(), "configured".to_string()); + } + + if let Some(style) = json_string(s, "outputStyle") { + cfg.extra.insert("outputStyle".to_string(), style); + } + } + + // MCP servers from ~/.claude.json + let mut extensions = Vec::new(); + if let Some(ref mc) = mcp_config { + if let Some(servers) = mc.get("mcpServers").and_then(|v| v.as_object()) { + for (name, _config) in servers { + extensions.push(ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }); + } + } + } + cfg.extensions = extensions; + + // Provider is always Anthropic for Claude Code. + cfg.extra + .insert("provider_locked".to_string(), "true".to_string()); + + Some(cfg) +} + +fn read_json_file(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn json_string(val: &serde_json::Value, key: &str) -> Option { + val.get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_settings(json: &str) -> RuntimeFileConfig { + use std::collections::BTreeMap; + let val: serde_json::Value = serde_json::from_str(json).unwrap(); + let mut extra = BTreeMap::new(); + if let Some(permissions) = val.get("permissions") { + if let Some(mode) = permissions.get("default").and_then(|v| v.as_str()) { + extra.insert("permissions.default".to_string(), mode.to_string()); + } + } + if val.get("hooks").is_some() { + extra.insert("hooks".to_string(), "configured".to_string()); + } + if let Some(style) = json_string(&val, "outputStyle") { + extra.insert("outputStyle".to_string(), style); + } + RuntimeFileConfig { + model: json_string(&val, "model"), + system_prompt: None, + extra, + ..Default::default() + } + } + + #[test] + fn parse_model_from_settings() { + let cfg = parse_settings(r#"{"model": "claude-sonnet-4-20250514"}"#); + assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4-20250514")); + } + + #[test] + fn parse_permissions_and_hooks() { + let cfg = parse_settings( + r#"{"permissions": {"default": "bypassPermissions"}, "hooks": {"pre-commit": {}}}"#, + ); + assert_eq!( + cfg.extra.get("permissions.default").map(|s| s.as_str()), + Some("bypassPermissions") + ); + assert_eq!( + cfg.extra.get("hooks").map(|s| s.as_str()), + Some("configured") + ); + } + + #[test] + fn parse_output_style_in_extra() { + let cfg = parse_settings(r#"{"outputStyle": "Be concise and technical"}"#); + assert_eq!( + cfg.extra.get("outputStyle").map(|s| s.as_str()), + Some("Be concise and technical") + ); + assert!(cfg.system_prompt.is_none()); + } + + #[test] + fn parse_mcp_servers() { + let json = + r#"{"mcpServers": {"filesystem": {"command": "npx"}, "github": {"command": "gh"}}}"#; + let val: serde_json::Value = serde_json::from_str(json).unwrap(); + let mut extensions = Vec::new(); + if let Some(servers) = val.get("mcpServers").and_then(|v| v.as_object()) { + for (name, _) in servers { + extensions.push(ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }); + } + } + assert_eq!(extensions.len(), 2); + } + + #[test] + fn empty_settings_returns_defaults() { + let cfg = parse_settings("{}"); + assert!(cfg.model.is_none()); + assert!(cfg.system_prompt.is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs new file mode 100644 index 000000000..721f7e8b3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -0,0 +1,170 @@ +use std::collections::BTreeMap; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read Codex config from `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`). +pub(super) fn read_config_file() -> Option { + let path = codex_config_path()?; + let raw = std::fs::read_to_string(path).ok()?; + parse_codex_config(&raw) +} + +fn parse_codex_config(toml_str: &str) -> Option { + let table: toml::Table = toml_str.parse().ok()?; + + let model = toml_string(&table, "model"); + let model_provider = toml_string(&table, "model_provider"); + let approval_policy = toml_string(&table, "approval_policy"); + let sandbox_mode = toml_string(&table, "sandbox_mode"); + let reasoning_effort = toml_string(&table, "model_reasoning_effort"); + let context_window = toml_string(&table, "model_context_window"); + + // Two-axis mode: approval_policy × sandbox_mode + let mode = match (approval_policy.as_deref(), sandbox_mode.as_deref()) { + (Some(ap), Some(sm)) => Some(format!("{ap}/{sm}")), + (Some(ap), None) => Some(ap.to_string()), + (None, Some(sm)) => Some(format!("default/{sm}")), + (None, None) => None, + }; + + let mut extra = BTreeMap::new(); + if let Some(ref ap) = approval_policy { + extra.insert("approval_policy".to_string(), ap.clone()); + } + if let Some(ref sm) = sandbox_mode { + extra.insert("sandbox_mode".to_string(), sm.clone()); + } + + // MCP servers from [mcp_servers.] tables + let extensions = parse_mcp_servers(&table); + + // Custom model providers from [model_providers.] + if let Some(providers) = table.get("model_providers").and_then(|v| v.as_table()) { + for (name, _) in providers { + extra.insert(format!("model_providers.{name}"), "configured".to_string()); + } + } + + Some(RuntimeFileConfig { + model, + provider: model_provider, + mode, + thinking_effort: reasoning_effort, + max_output_tokens: None, + context_limit: context_window, + system_prompt: toml_string(&table, "instructions"), + extensions, + extra, + }) +} + +fn parse_mcp_servers(table: &toml::Table) -> Vec { + let servers = match table.get("mcp_servers").and_then(|v| v.as_table()) { + Some(s) => s, + None => return Vec::new(), + }; + + servers + .iter() + .map(|(name, _config)| ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }) + .collect() +} + +fn toml_string(table: &toml::Table, key: &str) -> Option { + table + .get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn codex_config_path() -> Option { + if let Ok(home) = std::env::var("CODEX_HOME") { + return Some(std::path::PathBuf::from(home).join("config.toml")); + } + let home = dirs::home_dir()?; + Some(home.join(".codex").join("config.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_basic_config() { + let toml = r#" +model = "o3" +model_provider = "openai" +approval_policy = "unless-allow-listed" +sandbox_mode = "permissive" +model_reasoning_effort = "high" +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.model.as_deref(), Some("o3")); + assert_eq!(cfg.provider.as_deref(), Some("openai")); + assert_eq!(cfg.mode.as_deref(), Some("unless-allow-listed/permissive")); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + } + + #[test] + fn parse_mcp_servers() { + let toml = r#" +model = "gpt-4.1" + +[mcp_servers.filesystem] +command = "npx" +args = ["-y", "@anthropic-ai/mcp-filesystem"] + +[mcp_servers.github] +command = "gh" +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.extensions.len(), 2); + } + + #[test] + fn parse_custom_providers() { + let toml = r#" +model = "my-model" +model_provider = "custom-provider" + +[model_providers.custom-provider] +base_url = "http://localhost:8080" +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("custom-provider")); + assert!(cfg.extra.contains_key("model_providers.custom-provider")); + } + + #[test] + fn approval_only_mode() { + let toml = r#"approval_policy = "on-failure""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.mode.as_deref(), Some("on-failure")); + } + + #[test] + fn sandbox_only_mode() { + let toml = r#"sandbox_mode = "strict""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.mode.as_deref(), Some("default/strict")); + } + + #[test] + fn empty_config() { + let cfg = parse_codex_config("").unwrap(); + assert!(cfg.model.is_none()); + assert!(cfg.provider.is_none()); + assert!(cfg.mode.is_none()); + } + + #[test] + fn invalid_toml_returns_none() { + assert!(parse_codex_config("{{{{not valid").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs new file mode 100644 index 000000000..faf1fe79d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -0,0 +1,249 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read goose config from `~/.config/goose/config.yaml` (or `$GOOSE_PATH_ROOT`). +pub(super) fn read_config_file() -> Option { + let path = goose_config_path()?; + read_config_from_path(&path) +} + +fn read_config_from_path(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + parse_goose_config(&raw) +} + +fn parse_goose_config(yaml_str: &str) -> Option { + let map: std::collections::HashMap = + serde_yaml::from_str(yaml_str).ok()?; + + let active_provider = yaml_string(&map, "active_provider"); + + // Flat-key extraction (top-level env-style keys). + let goose_provider = yaml_string(&map, "GOOSE_PROVIDER"); + let goose_model = yaml_string(&map, "GOOSE_MODEL"); + let goose_mode = yaml_string(&map, "GOOSE_MODE"); + let goose_max_tokens = yaml_string(&map, "GOOSE_MAX_TOKENS"); + let goose_context_limit = yaml_string(&map, "GOOSE_CONTEXT_LIMIT"); + + // Nested provider format: active_provider → providers..{model,host,...} + let nested = active_provider + .as_deref() + .and_then(|ap| nested_provider_fields(&map, ap)); + + let provider = goose_provider.or_else(|| active_provider.clone()); + let model = goose_model.or_else(|| nested.as_ref().and_then(|n| n.model.clone())); + let mode = goose_mode; + + let extensions = parse_extensions(&map); + + let mut extra = BTreeMap::new(); + if let Some(ref ap) = active_provider { + extra.insert("active_provider".to_string(), ap.clone()); + } + if let Some(host) = yaml_string(&map, "DATABRICKS_HOST") + .or_else(|| nested.as_ref().and_then(|n| n.host.clone())) + { + let host_key = match active_provider.as_deref() { + Some("databricks_v2") | Some("databricks") => "DATABRICKS_HOST".to_string(), + Some(p) => format!("{p}.host"), + None => "provider.host".to_string(), + }; + extra.insert(host_key, host); + } + + Some(RuntimeFileConfig { + model, + provider, + mode, + thinking_effort: yaml_string(&map, "GOOSE_THINKING_EFFORT"), + max_output_tokens: goose_max_tokens, + context_limit: goose_context_limit, + system_prompt: None, + extensions, + extra, + }) +} + +struct NestedProviderFields { + model: Option, + host: Option, +} + +fn nested_provider_fields( + map: &std::collections::HashMap, + active_provider: &str, +) -> Option { + let providers = map.get("providers").and_then(|v| v.as_mapping())?; + let entry = providers + .get(serde_yaml::Value::String(active_provider.to_owned()))? + .as_mapping()?; + + let model = mapping_string(entry, "model"); + let host = mapping_string(entry, "host"); + + Some(NestedProviderFields { model, host }) +} + +fn parse_extensions( + map: &std::collections::HashMap, +) -> Vec { + let extensions = match map.get("extensions").and_then(|v| v.as_mapping()) { + Some(m) => m, + None => return Vec::new(), + }; + + extensions + .iter() + .filter_map(|(k, v)| { + let name = k.as_str()?.to_string(); + let kind = v + .as_mapping() + .and_then(|m| mapping_string(m, "type")) + .unwrap_or_else(|| "unknown".to_string()); + let enabled = v + .as_mapping() + .and_then(|m| { + m.get(serde_yaml::Value::String("enabled".to_owned())) + .and_then(|v| v.as_bool()) + }) + .unwrap_or(true); + Some(ExtensionEntry { + name, + kind, + enabled, + }) + }) + .collect() +} + +fn yaml_string( + map: &std::collections::HashMap, + key: &str, +) -> Option { + map.get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_owned())) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn goose_config_path() -> Option { + if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { + return Some(PathBuf::from(root).join("config").join("config.yaml")); + } + let home = dirs::home_dir()?; + Some(home.join(".config").join("goose").join("config.yaml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_flat_keys() { + let yaml = r#" +GOOSE_PROVIDER: anthropic +GOOSE_MODEL: claude-sonnet-4-20250514 +GOOSE_MODE: auto +GOOSE_MAX_TOKENS: "8192" +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4-20250514")); + assert_eq!(cfg.mode.as_deref(), Some("auto")); + assert_eq!(cfg.max_output_tokens.as_deref(), Some("8192")); + } + + #[test] + fn parse_nested_provider() { + let yaml = r#" +active_provider: databricks_v2 +providers: + databricks_v2: + model: goose-claude-4-6-opus + host: https://dbc.example +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("databricks_v2")); + assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-opus")); + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://dbc.example") + ); + } + + #[test] + fn non_databricks_provider_uses_provider_host_key() { + let yaml = r#" +active_provider: anthropic +providers: + anthropic: + model: claude-opus-4 + host: https://api.anthropic.com +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("anthropic")); + assert_eq!( + cfg.extra.get("anthropic.host").map(|s| s.as_str()), + Some("https://api.anthropic.com") + ); + assert!(!cfg.extra.contains_key("DATABRICKS_HOST")); + } + + #[test] + fn flat_model_wins_over_nested() { + let yaml = r#" +active_provider: databricks_v2 +GOOSE_MODEL: flat-model +providers: + databricks_v2: + model: nested-model +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.model.as_deref(), Some("flat-model")); + } + + #[test] + fn parse_extensions() { + let yaml = r#" +extensions: + developer: + type: builtin + enabled: true + my-mcp: + type: stdio + enabled: false +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.extensions.len(), 2); + assert!(cfg + .extensions + .iter() + .any(|e| e.name == "developer" && e.enabled)); + assert!(cfg + .extensions + .iter() + .any(|e| e.name == "my-mcp" && !e.enabled)); + } + + #[test] + fn invalid_yaml_returns_none() { + assert!(parse_goose_config("{{{{not valid").is_none()); + } + + #[test] + fn empty_yaml_returns_empty_config() { + let cfg = parse_goose_config("{}").unwrap(); + assert!(cfg.model.is_none()); + assert!(cfg.provider.is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs new file mode 100644 index 000000000..dd42f0463 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -0,0 +1,9 @@ +mod buzz_agent; +mod claude; +mod codex; +mod goose; +pub(crate) mod reader; +pub(crate) mod types; +pub(crate) mod writer; + +pub(crate) use types::*; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs new file mode 100644 index 000000000..46d57c7e9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -0,0 +1,582 @@ +use crate::managed_agents::discovery::KnownAcpRuntime; +use crate::managed_agents::types::ManagedAgentRecord; + +use super::types::*; + +/// Build the full config surface for an agent, merging all four tiers. +/// +/// Pre-spawn (no session cache): tiers 2a (env vars / record) and 2b (config files). +/// Post-spawn (session cache present): adds tiers 1a (ACP native) and 1b (ACP configOptions). +pub(crate) fn read_config_surface( + record: &ManagedAgentRecord, + runtime_meta: Option<&KnownAcpRuntime>, + session_cache: Option<&SessionConfigCache>, +) -> RuntimeConfigSurface { + let is_pre_spawn = session_cache.is_none(); + + // Tier 2b: config file values. + let (file_config, file_was_read) = runtime_meta + .map(|m| m.id) + .and_then(|id| match id { + "goose" => super::goose::read_config_file().map(|c| (c, true)), + "claude" => super::claude::read_config_file().map(|c| (c, true)), + "codex" => super::codex::read_config_file().map(|c| (c, true)), + "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), + _ => None, + }) + .unwrap_or_else(|| (RuntimeFileConfig::default(), false)); + + // Tier 2a: record-level values (Buzz-explicit). + let record_model = record.model.clone(); + let record_provider = record + .env_vars + .get(runtime_meta.and_then(|m| m.provider_env_var).unwrap_or("")) + .cloned(); + + let supports_acp_model = runtime_meta.is_some_and(|m| m.supports_acp_model_switching); + let model_env_var = runtime_meta.and_then(|m| m.model_env_var); + 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 supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config); + + // Tier 1b: ACP configOptions from session cache. + let acp_model = session_cache.and_then(|c| c.current_model.clone()); + let acp_mode = session_cache.and_then(|c| find_config_option_value(c, "mode")); + let acp_effort = session_cache.and_then(|c| find_config_option_value(c, "effort")); + let record_effort = thinking_env_var + .and_then(|k| record.env_vars.get(k)) + .cloned(); + + let normalized = NormalizedConfig { + model: Some(build_model_field( + &record_model, + &file_config.model, + &acp_model, + model_env_var, + supports_acp_model, + is_pre_spawn, + session_cache, + )), + provider: build_provider_field( + &record_provider, + &file_config.provider, + provider_env_var, + provider_locked, + ), + mode: build_mode_field(&file_config.mode, &acp_mode, is_pre_spawn, session_cache), + thinking_effort: build_thinking_field( + &record_effort, + &file_config.thinking_effort, + &acp_effort, + thinking_env_var, + is_pre_spawn, + session_cache, + ), + max_output_tokens: file_config + .max_output_tokens + .as_ref() + .map(|v| NormalizedField { + value: Some(v.clone()), + origin: ConfigOrigin::ConfigFile, + is_writable: false, + write_via: ConfigWriteMechanism::ReadOnly, + overridden_value: None, + overridden_origin: None, + }), + context_limit: file_config.context_limit.as_ref().map(|v| NormalizedField { + value: Some(v.clone()), + origin: ConfigOrigin::ConfigFile, + is_writable: false, + write_via: ConfigWriteMechanism::ReadOnly, + overridden_value: None, + overridden_origin: None, + }), + system_prompt: { + let record_system_prompt = record + .system_prompt + .clone() + .or_else(|| record.env_vars.get("BUZZ_ACP_SYSTEM_PROMPT").cloned()); + record_system_prompt.as_ref().map(|v| NormalizedField { + value: Some(v.clone()), + origin: ConfigOrigin::BuzzExplicit, + is_writable: true, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + }, + overridden_value: file_config.system_prompt.clone(), + overridden_origin: file_config + .system_prompt + .as_ref() + .map(|_| ConfigOrigin::ConfigFile), + }) + }, + }; + + // Advanced fields from config file extras. + let advanced: Vec = file_config + .extra + .iter() + .map(|(k, v)| ConfigField { + key: k.clone(), + label: k.clone(), + value: Some(v.clone()), + origin: ConfigOrigin::ConfigFile, + schema_type: ConfigFieldType::String, + is_writable: false, + write_via: ConfigWriteMechanism::ReadOnly, + }) + .collect(); + + let config_file_path = runtime_meta + .and_then(|m| m.config_file_path) + .map(resolve_tilde); + + let sources = ConfigSourceReport { + acp_native: if supports_acp_native { + if session_cache + .and_then(|c| c.goose_native_config.as_ref()) + .is_some() + { + ConfigTierStatus::Available + } else { + // Post-spawn without native config data is also Pending — it arrives + // asynchronously after the session/new response. + ConfigTierStatus::Pending + } + } else { + ConfigTierStatus::NotApplicable + }, + acp_config_options: if is_pre_spawn { + ConfigTierStatus::Pending + } else if session_cache.is_some_and(|c| !c.config_options.is_empty()) { + ConfigTierStatus::Available + } else { + ConfigTierStatus::NotApplicable + }, + env_vars: ConfigTierStatus::Available, + config_file: if file_was_read { + ConfigTierStatus::Available + } else { + ConfigTierStatus::NotApplicable + }, + config_file_path, + }; + + RuntimeConfigSurface { + runtime_id: runtime_meta.map(|m| m.id.to_string()), + runtime_label: runtime_meta.map(|m| m.label.to_string()), + is_pre_spawn, + normalized, + advanced, + sources, + } +} + +fn build_model_field( + record_model: &Option, + file_model: &Option, + acp_model: &Option, + model_env_var: Option<&str>, + supports_acp_model: bool, + is_pre_spawn: bool, + session_cache: Option<&SessionConfigCache>, +) -> NormalizedField { + // Precedence: Buzz-explicit > ACP current > config file + let (value, origin) = if let Some(ref m) = record_model { + (Some(m.clone()), ConfigOrigin::BuzzExplicit) + } else if let Some(ref m) = acp_model { + (Some(m.clone()), ConfigOrigin::AcpConfigOption) + } else if let Some(ref m) = file_model { + (Some(m.clone()), ConfigOrigin::ConfigFile) + } else { + (None, ConfigOrigin::EnvVar) + }; + + let overridden_value = if record_model.is_some() { + file_model.clone().or(acp_model.clone()) + } else if acp_model.is_some() && file_model.is_some() { + file_model.clone() + } else { + None + }; + let overridden_origin = if record_model.is_some() && file_model.is_some() { + Some(ConfigOrigin::ConfigFile) + } else if record_model.is_some() && acp_model.is_some() { + Some(ConfigOrigin::AcpConfigOption) + } else if acp_model.is_some() && file_model.is_some() { + Some(ConfigOrigin::ConfigFile) + } else { + None + }; + + // Write mechanism: prefer ACP if post-spawn and supported. + let write_via = if !is_pre_spawn && has_config_option(session_cache, "model") { + let config_id = find_model_config_id(session_cache).unwrap_or_else(|| "model".to_string()); + ConfigWriteMechanism::AcpSetConfigOption { config_id } + } else if !is_pre_spawn && supports_acp_model { + ConfigWriteMechanism::AcpSetSessionModel + } else if let Some(env_key) = model_env_var { + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: env_key.to_string(), + } + } else { + ConfigWriteMechanism::ReadOnly + }; + + NormalizedField { + value, + origin, + is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), + write_via, + overridden_value, + overridden_origin, + } +} + +fn build_provider_field( + record_provider: &Option, + file_provider: &Option, + provider_env_var: Option<&str>, + provider_locked: bool, +) -> Option { + if provider_locked { + return Some(NormalizedField { + value: Some("Anthropic (locked)".to_string()), + origin: ConfigOrigin::EnvVar, + is_writable: false, + write_via: ConfigWriteMechanism::ReadOnly, + overridden_value: None, + overridden_origin: None, + }); + } + + let (value, origin) = if let Some(ref p) = record_provider { + (Some(p.clone()), ConfigOrigin::BuzzExplicit) + } else if let Some(ref p) = file_provider { + (Some(p.clone()), ConfigOrigin::ConfigFile) + } else { + return None; + }; + + let write_via = if let Some(env_key) = provider_env_var { + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: env_key.to_string(), + } + } else { + ConfigWriteMechanism::ReadOnly + }; + + Some(NormalizedField { + value, + origin, + is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), + write_via, + overridden_value: if record_provider.is_some() { + file_provider.clone() + } else { + None + }, + overridden_origin: if record_provider.is_some() && file_provider.is_some() { + Some(ConfigOrigin::ConfigFile) + } else { + None + }, + }) +} + +fn build_mode_field( + file_mode: &Option, + acp_mode: &Option, + is_pre_spawn: bool, + session_cache: Option<&SessionConfigCache>, +) -> Option { + let (value, origin) = if let Some(ref m) = acp_mode { + (Some(m.clone()), ConfigOrigin::AcpConfigOption) + } else if let Some(ref m) = file_mode { + (Some(m.clone()), ConfigOrigin::ConfigFile) + } else { + return None; + }; + + let write_via = if !is_pre_spawn && has_config_option(session_cache, "mode") { + ConfigWriteMechanism::AcpSetConfigOption { + config_id: "mode".to_string(), + } + } else { + ConfigWriteMechanism::ReadOnly + }; + + Some(NormalizedField { + value, + origin, + is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), + write_via, + overridden_value: if acp_mode.is_some() { + file_mode.clone() + } else { + None + }, + overridden_origin: if acp_mode.is_some() && file_mode.is_some() { + Some(ConfigOrigin::ConfigFile) + } else { + None + }, + }) +} + +fn build_thinking_field( + record_effort: &Option, + file_effort: &Option, + acp_effort: &Option, + thinking_env_var: Option<&str>, + is_pre_spawn: bool, + session_cache: Option<&SessionConfigCache>, +) -> Option { + let (value, origin) = if let Some(ref e) = record_effort { + (Some(e.clone()), ConfigOrigin::BuzzExplicit) + } else if let Some(ref e) = acp_effort { + (Some(e.clone()), ConfigOrigin::AcpConfigOption) + } else if let Some(ref e) = file_effort { + (Some(e.clone()), ConfigOrigin::ConfigFile) + } else { + return None; + }; + + let write_via = if !is_pre_spawn && has_config_option(session_cache, "effort") { + ConfigWriteMechanism::AcpSetConfigOption { + config_id: "effort".to_string(), + } + } else if let Some(env_key) = thinking_env_var { + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: env_key.to_string(), + } + } else { + ConfigWriteMechanism::ReadOnly + }; + + Some(NormalizedField { + value, + origin, + is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), + write_via, + overridden_value: if record_effort.is_some() { + acp_effort.clone().or(file_effort.clone()) + } else if acp_effort.is_some() { + file_effort.clone() + } else { + None + }, + overridden_origin: if record_effort.is_some() && acp_effort.is_some() { + Some(ConfigOrigin::AcpConfigOption) + } else if file_effort.is_some() && (record_effort.is_some() || acp_effort.is_some()) { + Some(ConfigOrigin::ConfigFile) + } else { + None + }, + }) +} + +// ── ACP cache helpers ──────────────────────────────────────────────────────── + +fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Option { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some(category)) + .and_then(|o| o.current_value.clone()) +} + +fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { + cache.is_some_and(|c| { + c.config_options + .iter() + .any(|o| o.category.as_deref() == Some(category)) + }) +} + +fn find_model_config_id(cache: Option<&SessionConfigCache>) -> Option { + cache.and_then(|c| { + c.config_options + .iter() + .find(|o| o.category.as_deref() == Some("model")) + .map(|o| o.config_id.clone()) + }) +} + +fn resolve_tilde(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).display().to_string(); + } + } + path.to_string() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::discovery::KnownAcpRuntime; + use crate::managed_agents::types::ManagedAgentRecord; + + fn test_runtime() -> &'static KnownAcpRuntime { + &KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[], + 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"), + } + } + + fn test_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, + mcp_toolsets: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + relay_mesh: None, + } + } + + #[test] + fn pre_spawn_surface_reports_pending_acp_tiers() { + let record = test_record(); + let runtime = test_runtime(); + let surface = read_config_surface(&record, Some(runtime), None); + + assert!(surface.is_pre_spawn); + assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); + assert_eq!( + surface.sources.acp_config_options, + ConfigTierStatus::Pending + ); + assert_eq!(surface.sources.env_vars, ConfigTierStatus::Available); + } + + #[test] + fn record_model_overrides_file_model() { + let mut record = test_record(); + record.model = Some("explicit-model".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None); + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + } + + #[test] + fn provider_locked_shows_locked() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + provider_locked: true, + ..*test_runtime() + }; + let surface = read_config_surface(&record, Some(runtime), None); + let provider = surface.normalized.provider.unwrap(); + assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); + assert!(!provider.is_writable); + } + + #[test] + fn post_spawn_with_model_config_option_uses_acp() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "model".to_string(), + category: Some("model".to_string()), + display_name: Some("Model".to_string()), + current_value: Some("claude-opus-4".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: Some("claude-opus-4".to_string()), + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache)); + assert!(!surface.is_pre_spawn); + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("claude-opus-4")); + assert!(matches!( + model.write_via, + ConfigWriteMechanism::AcpSetConfigOption { .. } + )); + } + + #[test] + fn acp_model_overrides_file_model_with_override_tracking() { + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("acp-model".to_string()), + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache)); + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("acp-model")); + assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); + // The goose config file might have a model too — since we can't control + // the actual file in a unit test, just verify the override fields are populated + // when we manually construct the scenario via build_model_field. + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs new file mode 100644 index 000000000..5324eec43 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -0,0 +1,217 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Where a config value came from — determines precedence and UI annotations. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ConfigOrigin { + /// Explicitly set in Buzz UI / ManagedAgentRecord (highest precedence). + BuzzExplicit, + /// Returned by ACP `_goose/unstable/config/read` (tier 1a). + AcpNativeRead, + /// Returned by ACP `session/new` configOptions (tier 1b). + AcpConfigOption, + /// Set via env var at spawn time (tier 2a). + EnvVar, + /// Read from harness config file on disk (tier 2b, lowest precedence). + ConfigFile, + /// Value inherited from persona defaults. + /// Forward slot — not yet populated by any reader. Will be wired when + /// persona pack config resolution is added to `read_config_surface`. + PersonaDefault, +} + +/// How a config field can be written back to the runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ConfigWriteMechanism { + /// Update record env vars, save, stop + restart agent. + RespawnWithEnvVar { env_key: String }, + /// Send `session/set_config_option` via ACP (live, no restart). + AcpSetConfigOption { config_id: String }, + /// Send `session/set_model` via ACP (live, no restart). + AcpSetSessionModel, + /// Send `_goose/unstable/config/write` sparse patch (live, no restart). + /// Reserved for tier 1a — blocked on upstream goose PR landing. + /// Not yet constructed by any reader; will be wired when config/read+write + /// are available in the harness. + GooseNativeConfigWrite { config_key: String }, + /// Not writable through Buzz. + ReadOnly, +} + +/// A single normalized config field with provenance and write metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NormalizedField { + pub value: Option, + pub origin: ConfigOrigin, + pub is_writable: bool, + pub write_via: ConfigWriteMechanism, + /// When this field overrides a lower-precedence value, show what it overrode. + pub overridden_value: Option, + pub overridden_origin: Option, +} + +/// Normalized cross-runtime config concepts (~8 fields that span all runtimes). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NormalizedConfig { + pub model: Option, + pub provider: Option, + pub mode: Option, + pub thinking_effort: Option, + pub max_output_tokens: Option, + pub context_limit: Option, + pub system_prompt: Option, +} + +/// A runtime-specific config field not covered by normalization. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigField { + pub key: String, + pub label: String, + pub value: Option, + pub origin: ConfigOrigin, + pub schema_type: ConfigFieldType, + pub is_writable: bool, + pub write_via: ConfigWriteMechanism, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ConfigFieldType { + String, + Number, + Boolean, + Enum { options: Vec }, +} + +/// Status of each config tier for the sources footer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ConfigTierStatus { + Available, + Pending, + NotApplicable, +} + +/// Report of which config tiers were consulted. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigSourceReport { + pub acp_native: ConfigTierStatus, + pub acp_config_options: ConfigTierStatus, + pub env_vars: ConfigTierStatus, + pub config_file: ConfigTierStatus, + pub config_file_path: Option, +} + +/// Full config surface returned to the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeConfigSurface { + pub runtime_id: Option, + pub runtime_label: Option, + pub is_pre_spawn: bool, + pub normalized: NormalizedConfig, + pub advanced: Vec, + pub sources: ConfigSourceReport, +} + +/// Request to write a config field value. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteConfigFieldRequest { + pub pubkey: String, + pub field: WriteConfigTarget, + pub value: Option, +} + +/// Which config field to write. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum WriteConfigTarget { + Model, + Provider, + Mode, + ThinkingEffort, + MaxOutputTokens, + ContextLimit, + SystemPrompt, + Advanced { key: String }, +} + +/// Result of a config write operation. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteConfigResult { + pub success: bool, + pub mechanism_used: ConfigWriteMechanism, + pub requires_restart: bool, + pub error: Option, +} + +/// Raw config values extracted from a runtime's config file. +#[derive(Debug, Clone, Default)] +pub struct RuntimeFileConfig { + pub model: Option, + pub provider: Option, + pub mode: Option, + pub thinking_effort: Option, + pub max_output_tokens: Option, + pub context_limit: Option, + pub system_prompt: Option, + pub extensions: Vec, + pub extra: BTreeMap, +} + +/// A detected MCP server or extension from a config file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionEntry { + pub name: String, + pub kind: String, + pub enabled: bool, +} + +/// Cached ACP session config from a running agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfigCache { + pub config_options: Vec, + pub available_modes: Vec, + pub available_models: Vec, + pub current_model: Option, + pub goose_native_config: Option, + pub captured_at: String, +} + +/// A single ACP configOption from session/new. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigOptionEntry { + pub config_id: String, + pub category: Option, + pub display_name: Option, + pub current_value: Option, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigOptionValue { + pub value: String, + pub display_name: Option, +} + +/// A model entry from ACP session/new. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpModelEntry { + pub model_id: String, + pub name: Option, + pub description: Option, +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/writer.rs b/desktop/src-tauri/src/managed_agents/config_bridge/writer.rs new file mode 100644 index 000000000..51307935c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/writer.rs @@ -0,0 +1,146 @@ +use super::types::*; + +/// Route a config write to the correct mechanism and return the result. +/// +/// This does NOT execute the write — it determines what mechanism should be +/// used and returns the `WriteConfigResult` describing the action. The caller +/// (Tauri command) is responsible for executing the actual write (updating +/// the record and restarting, or sending an observer control event). +pub(crate) fn plan_config_write( + surface: &RuntimeConfigSurface, + target: &WriteConfigTarget, +) -> WriteConfigResult { + let field = match target { + WriteConfigTarget::Model => surface.normalized.model.as_ref(), + WriteConfigTarget::Provider => surface.normalized.provider.as_ref(), + WriteConfigTarget::Mode => surface.normalized.mode.as_ref(), + WriteConfigTarget::ThinkingEffort => surface.normalized.thinking_effort.as_ref(), + WriteConfigTarget::MaxOutputTokens => surface.normalized.max_output_tokens.as_ref(), + WriteConfigTarget::ContextLimit => surface.normalized.context_limit.as_ref(), + WriteConfigTarget::SystemPrompt => surface.normalized.system_prompt.as_ref(), + WriteConfigTarget::Advanced { key } => { + let adv = surface.advanced.iter().find(|f| f.key == *key); + return match adv { + Some(f) if f.is_writable => WriteConfigResult { + success: true, + mechanism_used: f.write_via.clone(), + requires_restart: matches!( + f.write_via, + ConfigWriteMechanism::RespawnWithEnvVar { .. } + ), + error: None, + }, + Some(_) => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some(format!("field '{key}' is read-only")), + }, + None => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some(format!("unknown advanced field '{key}'")), + }, + }; + } + }; + + match field { + Some(f) if f.is_writable => WriteConfigResult { + success: true, + mechanism_used: f.write_via.clone(), + requires_restart: matches!(f.write_via, ConfigWriteMechanism::RespawnWithEnvVar { .. }), + error: None, + }, + Some(_) => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some("field is read-only".to_string()), + }, + None => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some("field not available for this runtime".to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn surface_with_writable_model() -> RuntimeConfigSurface { + RuntimeConfigSurface { + runtime_id: Some("goose".to_string()), + runtime_label: Some("Goose".to_string()), + is_pre_spawn: false, + normalized: NormalizedConfig { + model: Some(NormalizedField { + value: Some("claude-opus-4".to_string()), + origin: ConfigOrigin::BuzzExplicit, + is_writable: true, + write_via: ConfigWriteMechanism::AcpSetConfigOption { + config_id: "model".to_string(), + }, + overridden_value: None, + overridden_origin: None, + }), + provider: None, + mode: None, + thinking_effort: None, + max_output_tokens: None, + context_limit: None, + system_prompt: None, + }, + advanced: vec![], + sources: ConfigSourceReport { + acp_native: ConfigTierStatus::NotApplicable, + acp_config_options: ConfigTierStatus::Available, + env_vars: ConfigTierStatus::Available, + config_file: ConfigTierStatus::NotApplicable, + config_file_path: None, + }, + } + } + + #[test] + fn writable_model_returns_acp_mechanism() { + let surface = surface_with_writable_model(); + let result = plan_config_write(&surface, &WriteConfigTarget::Model); + assert!(result.success); + assert!(!result.requires_restart); + assert!(matches!( + result.mechanism_used, + ConfigWriteMechanism::AcpSetConfigOption { .. } + )); + } + + #[test] + fn missing_field_returns_error() { + let surface = surface_with_writable_model(); + let result = plan_config_write(&surface, &WriteConfigTarget::Mode); + assert!(!result.success); + assert!(result.error.is_some()); + } + + #[test] + fn respawn_mechanism_requires_restart() { + let mut surface = surface_with_writable_model(); + surface.normalized.model = Some(NormalizedField { + value: Some("my-model".to_string()), + origin: ConfigOrigin::EnvVar, + is_writable: true, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODEL".to_string(), + }, + overridden_value: None, + overridden_origin: None, + }); + let result = plan_config_write(&surface, &WriteConfigTarget::Model); + assert!(result.success); + assert!(result.requires_restart); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2e3518b50..5830d062e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -42,6 +42,11 @@ pub(crate) struct KnownAcpRuntime { pub provider_env_var: Option<&'static str>, pub provider_locked: bool, pub default_env: &'static [(&'static str, &'static str)], + pub config_file_path: Option<&'static str>, + #[allow(dead_code)] // reserved for format-based dispatch when readers are unified + pub config_file_format: Option<&'static str>, + pub supports_acp_native_config: bool, // tier 1a: config/read+write + pub thinking_env_var: Option<&'static str>, } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -93,6 +98,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ 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"), }, KnownAcpRuntime { id: "claude", @@ -114,6 +123,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ 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, }, KnownAcpRuntime { id: "codex", @@ -133,8 +146,12 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ supports_acp_model_switching: false, model_env_var: None, provider_env_var: None, - provider_locked: true, + 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, }, KnownAcpRuntime { id: "buzz-agent", @@ -156,6 +173,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ 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: None, }, ]; diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index f4f55c206..3d928dbfb 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -429,7 +429,7 @@ fn is_derived_key_matches_all_known_keys() { for key in DERIVED_PROVIDER_MODEL_ENV_KEYS { assert!( is_derived_provider_model_key(key), - "{key} should be recognized as derived" + "expected `{key}` to be recognized as derived" ); } } @@ -455,25 +455,35 @@ fn is_derived_key_does_not_match_unrelated_keys() { #[test] fn filter_derived_strips_provider_model_keys_preserves_rest() { let input = vec![ - ( - "GOOSE_MODEL".to_string(), - "claude-sonnet-4-20250514".to_string(), - ), - ("GOOSE_PROVIDER".to_string(), "anthropic".to_string()), + ("GOOSE_MODEL".to_string(), "old-model".to_string()), + ("GOOSE_PROVIDER".to_string(), "old-provider".to_string()), ("BUZZ_AGENT_MODEL".to_string(), "gpt-4o".to_string()), ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), ("GOOSE_TEMPERATURE".to_string(), "0.7".to_string()), - ("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string()), + ("GOOSE_CONTEXT_LIMIT".to_string(), "128000".to_string()), + ("CUSTOM_KEY".to_string(), "custom-value".to_string()), ]; + let filtered = filter_derived_provider_model_env_vars(input); - assert_eq!(filtered.len(), 2); + + // Derived keys must be gone. + assert!(!filtered.contains_key("GOOSE_MODEL")); + assert!(!filtered.contains_key("GOOSE_PROVIDER")); + assert!(!filtered.contains_key("BUZZ_AGENT_MODEL")); + assert!(!filtered.contains_key("BUZZ_AGENT_PROVIDER")); + + // Non-derived keys must survive. assert_eq!( filtered.get("GOOSE_TEMPERATURE").map(String::as_str), Some("0.7") ); assert_eq!( - filtered.get("ANTHROPIC_API_KEY").map(String::as_str), - Some("sk-test") + filtered.get("GOOSE_CONTEXT_LIMIT").map(String::as_str), + Some("128000") + ); + assert_eq!( + filtered.get("CUSTOM_KEY").map(String::as_str), + Some("custom-value") ); } @@ -485,19 +495,76 @@ fn filter_derived_empty_input_returns_empty() { #[test] fn stale_derived_env_does_not_override_structured_fields() { - // Documents that merged_user_env is transparent to derived keys — it - // does NOT strip them. The defense is the import filter - // (filter_derived_provider_model_env_vars) which prevents them from - // being persisted in the first place. If a stale record somehow has - // them, they flow through merged_user_env unchanged — the spawn-time - // re-derivation from structured fields writes AFTER merged env. - let persona_env = map(&[("GOOSE_MODEL", "stale-model"), ("LEGIT", "v")]); - let merged = merged_user_env(&persona_env, &BTreeMap::new()); - // merged_user_env does NOT filter derived keys — that's by design. - // The import filter is the boundary defense. + // Scenario: A persona was imported WITH stale derived keys (pre-fix). + // At merge time, `merged_user_env` passes them through (it doesn't filter). + // The fix is at *import* time — this test documents that merged_user_env + // is transparent, and the import filter is the correct defense. + let stale_persona_env = map(&[ + ("BUZZ_AGENT_MODEL", "stale-model"), + ("BUZZ_AGENT_PROVIDER", "stale-provider"), + ("GOOSE_TEMPERATURE", "0.5"), + ]); + let agent_env = BTreeMap::new(); + + let merged = merged_user_env(&stale_persona_env, &agent_env); + + // merged_user_env is transparent — stale keys pass through. assert_eq!( - merged.get("GOOSE_MODEL").map(String::as_str), + merged.get("BUZZ_AGENT_MODEL").map(String::as_str), Some("stale-model") ); - assert_eq!(merged.get("LEGIT").map(String::as_str), Some("v")); + assert_eq!( + merged.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("stale-provider") + ); + + // But the import filter WOULD have caught them: + let would_be_filtered = filter_derived_provider_model_env_vars(stale_persona_env); + assert!(!would_be_filtered.contains_key("BUZZ_AGENT_MODEL")); + assert!(!would_be_filtered.contains_key("BUZZ_AGENT_PROVIDER")); + // Non-derived keys survive the filter. + assert_eq!( + would_be_filtered + .get("GOOSE_TEMPERATURE") + .map(String::as_str), + Some("0.5") + ); +} + +// ── deploy payload model precedence ──────────────────────────────── + +/// Documents the model precedence rule used by `build_deploy_payload`: +/// persona structured model is authoritative when present; the agent +/// record's `model` field is only a fallback. +/// +/// This mirrors local spawn behavior where `runtime_metadata_env_vars` +/// derives GOOSE_MODEL from the persona's structured field, not the +/// agent record. +#[test] +fn deploy_model_precedence_persona_wins_over_record() { + // Simulates the precedence logic from build_deploy_payload: + // let model = persona.model.clone().or(record.model.clone()); + let persona_model: Option = Some("claude-sonnet-4-20250514".to_string()); + let record_model: Option = Some("stale-record-model".to_string()); + + let effective = persona_model.clone().or(record_model.clone()); + assert_eq!(effective.as_deref(), Some("claude-sonnet-4-20250514")); +} + +#[test] +fn deploy_model_precedence_falls_back_to_record_when_persona_has_none() { + let persona_model: Option = None; + let record_model: Option = Some("record-model".to_string()); + + let effective = persona_model.clone().or(record_model.clone()); + assert_eq!(effective.as_deref(), Some("record-model")); +} + +#[test] +fn deploy_model_precedence_none_when_both_absent() { + let persona_model: Option = None; + let record_model: Option = None; + + let effective = persona_model.clone().or(record_model.clone()); + assert_eq!(effective, None); } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a3f4b447b..f6f9a9e95 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod agent_events; mod backend; +pub(crate) mod config_bridge; mod discovery; mod env_vars; mod nest; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ed4680978..48c8a6ce4 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -13,6 +13,7 @@ import { discoverAcpRuntimes, discoverBackendProviders, discoverManagedAgentPrereqs, + getAgentConfigSurface, getManagedAgentLog, installAcpRuntime, listManagedAgents, @@ -553,6 +554,19 @@ export function useManagedAgentLogQuery( }); } +export const agentConfigSurfaceQueryKey = (pubkey: string) => + ["agent-config-surface", pubkey] as const; + +export function useAgentConfigSurface(pubkey: string | null) { + return useQuery({ + queryKey: agentConfigSurfaceQueryKey(pubkey ?? ""), + queryFn: () => getAgentConfigSurface(pubkey ?? ""), + enabled: !!pubkey, + staleTime: 10_000, + refetchInterval: 30_000, + }); +} + export function useTeamsQuery() { return useQuery({ queryKey: teamsQueryKey, diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6f3370de0..8d5f5eb71 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -2,7 +2,7 @@ import * as React from "react"; import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay"; import type { RelayEvent, ManagedAgent } from "@/shared/api/types"; -import { getIdentity } from "@/shared/api/tauri"; +import { getIdentity, putAgentSessionConfig } from "@/shared/api/tauri"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { @@ -192,6 +192,9 @@ async function handleRelayObserverEvent( return; } appendAgentEvent(agentPubkey, parsed); + if (parsed.kind === "session_config_captured") { + void putAgentSessionConfig(agentPubkey, parsed.payload); + } } catch (error) { if (activeGeneration !== generation) { return; diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx new file mode 100644 index 000000000..decf3896d --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -0,0 +1,309 @@ +import * as React from "react"; +import { ChevronDown, ChevronRight, Info } from "lucide-react"; + +import { useAgentConfigSurface } from "../hooks"; +import { cn } from "@/shared/lib/cn"; +import { Spinner } from "@/shared/ui/spinner"; +import type { + ConfigField, + ConfigOrigin, + NormalizedConfig, + NormalizedField, + ConfigSourceReport, +} from "@/shared/api/types"; + +type Props = { + pubkey: string; + isRunning: boolean; +}; + +// ── Origin badge ───────────────────────────────────────────────────────────── + +function originLabel( + origin: ConfigOrigin, + configFilePath: string | null, +): string { + switch (origin) { + case "buzzExplicit": + return "Buzz"; + case "acpConfigOption": + return "ACP"; + case "acpNativeRead": + return "ACP"; + case "envVar": + return "Env"; + case "configFile": { + if (configFilePath) { + const parts = configFilePath.split(/[/\\]/); + return parts[parts.length - 1] ?? configFilePath; + } + return "Config"; + } + case "personaDefault": + return "Persona"; + } +} + +function originColorClass(origin: ConfigOrigin): string { + switch (origin) { + case "buzzExplicit": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"; + case "acpConfigOption": + case "acpNativeRead": + return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"; + case "configFile": + case "personaDefault": + return "bg-muted text-muted-foreground"; + case "envVar": + return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"; + } +} + +function OriginBadge({ + origin, + configFilePath, +}: { + origin: ConfigOrigin; + configFilePath: string | null; +}) { + return ( + + {originLabel(origin, configFilePath)} + + ); +} + +// ── Normalized row ──────────────────────────────────────────────────────────── + +const NORMALIZED_LABELS: Record = { + model: "Model", + provider: "Provider", + mode: "Mode", + thinkingEffort: "Thinking / Effort", + maxOutputTokens: "Max Output Tokens", + contextLimit: "Context Limit", + systemPrompt: "System Prompt", +}; + +function NormalizedRow({ + label, + field, + isPreSpawn, + configFilePath, +}: { + label: string; + field: NormalizedField; + isPreSpawn: boolean; + configFilePath: string | null; +}) { + // ACP-sourced origins only become meaningful post-spawn + const isAcpOnly = + field.origin === "acpNativeRead" || field.origin === "acpConfigOption"; + + return ( +
+ + {label} + + + {/* Value area: effective value + strikethrough overridden value */} + + {isPreSpawn && isAcpOnly ? ( + + Available after agent starts + + ) : isPreSpawn && field.origin === "configFile" && !field.value ? ( + + ) : ( + <> + {field.value ?? } + {field.overriddenValue && ( + + {field.overriddenValue} + + )} + + )} + + + {/* Badge area: effective badge + strikethrough overridden badge */} + + + {field.overriddenOrigin && ( + + + + )} + + + {!field.isWritable && ( + + + + )} +
+ ); +} + +// ── Advanced row ────────────────────────────────────────────────────────────── + +function AdvancedRow({ + field, + configFilePath, +}: { + field: ConfigField; + configFilePath: string | null; +}) { + return ( +
+ + {field.label} + + + {field.value ?? ( + + )} + + + {!field.isWritable && ( + + + + )} +
+ ); +} + +// ── Sources footer ──────────────────────────────────────────────────────────── + +const STATUS_ICON: Record = { + available: "✓", + pending: "⏳", + notApplicable: "—", +}; + +function SourcesFooter({ sources }: { sources: ConfigSourceReport }) { + const tiers = [ + { label: "Config file", status: sources.configFile }, + { label: "ACP native", status: sources.acpNative }, + { label: "ACP config", status: sources.acpConfigOptions }, + { label: "Env vars", status: sources.envVars }, + ] as const; + + return ( +

+ {tiers.map((tier, i) => ( + + {i > 0 && |} + {tier.label} {STATUS_ICON[tier.status] ?? tier.status} + + ))} +

+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) { + const [advancedOpen, setAdvancedOpen] = React.useState(false); + + const { data, isLoading, error } = useAgentConfigSurface(pubkey); + + if (isLoading) { + return ( +
+ + Loading config… +
+ ); + } + + if (error || !data) { + return ( +

+ {error instanceof Error + ? error.message + : "Failed to load agent config."} +

+ ); + } + + const { normalized, advanced, sources, isPreSpawn } = data; + const configFilePath = sources.configFilePath; + + const normalizedEntries = ( + Object.entries(normalized) as [ + keyof NormalizedConfig, + NormalizedField | null, + ][] + ).filter(([, field]) => field !== null) as [ + keyof NormalizedConfig, + NormalizedField, + ][]; + + return ( +
+ {/* Normalized section */} +
+ {normalizedEntries.length === 0 ? ( +

+ No config fields available. +

+ ) : ( + normalizedEntries.map(([key, field]) => ( + + )) + )} +
+ + {/* Advanced section */} + {advanced.length > 0 && ( +
+ + + {advancedOpen && ( +
+ {advanced.map((field) => ( + + ))} +
+ )} +
+ )} + + +
+ ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 696003e35..60ec3e112 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -36,6 +36,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { AgentConfigPanel } from "./AgentConfigPanel"; import { EditAgentDialog } from "./EditAgentDialog"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; @@ -208,6 +209,15 @@ export function ManagedAgentRow({ selectedAgent={agent} variant="inline" /> +
+

+ Configuration +

+ +
) : null} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 12950201e..ec3ab0362 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -3,8 +3,16 @@ import { ChevronDown } from "lucide-react"; import { Spinner } from "@/shared/ui/spinner"; import React from "react"; -import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types"; -import { getAgentModels, updateManagedAgent } from "@/shared/api/tauri"; +import type { + AgentModelsResponse, + ManagedAgent, + RuntimeConfigSurface, +} from "@/shared/api/types"; +import { + getAgentConfigSurface, + getAgentModels, + updateManagedAgent, +} from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -23,6 +31,8 @@ export function ModelPicker({ }) { const [modelsData, setModelsData] = React.useState(null); + const [configSurface, setConfigSurface] = + React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); @@ -49,9 +59,21 @@ export function ModelPicker({ } setHasRequestedModels(true); + // Fetch config surface for model provenance data alongside the model list. + // The config surface call is best-effort — a failure doesn't block model + // selection, it just means we won't show the origin badge. + void getAgentConfigSurface(agent.pubkey) + .then((surface) => { + if (!surface.isPreSpawn) { + setConfigSurface(surface); + } + }) + .catch(() => { + // Intentionally swallowed — provenance badge is informational only. + }); void fetchModels(); }, - [fetchModels, loading, modelsData], + [agent.pubkey, fetchModels, loading, modelsData], ); const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? ""; @@ -63,6 +85,22 @@ export function ModelPicker({ ? "Loading..." : "Auto"); + // Provenance label shown only for post-spawn agents where the model origin + // is known from the config surface and the source is not a user-explicit + // Buzz setting (which is already self-evident from the picker state). + const modelOriginLabel = React.useMemo(() => { + const origin = configSurface?.normalized.model?.origin; + if (!origin || origin === "buzzExplicit") return null; + const labels: Record = { + acpNativeRead: "from ACP", + acpConfigOption: "from ACP config", + envVar: "from env", + configFile: "from config file", + personaDefault: "persona default", + }; + return labels[origin] ?? null; + }, [configSurface]); + const handleModelChange = async (modelId: string) => { setSaving(true); try { @@ -93,6 +131,11 @@ export function ModelPicker({ variant="ghost" > {displayLabel} + {modelOriginLabel ? ( + + ({modelOriginLabel}) + + ) : null} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 33291ffe6..c6bcdaeae 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -42,6 +42,9 @@ import type { CommandAvailability, InstallRuntimeResult, OpenDmInput, + RuntimeConfigSurface, + WriteConfigFieldRequest, + WriteConfigResult, } from "@/shared/api/types"; type RawIdentity = { @@ -1124,6 +1127,29 @@ export async function getAgentModels(pubkey: string) { return invokeTauri("get_agent_models", { pubkey }); } +export async function getAgentConfigSurface( + pubkey: string, +): Promise { + return invokeTauri("get_agent_config_surface", { + pubkey, + }); +} + +export async function writeAgentConfigField( + request: WriteConfigFieldRequest, +): Promise { + return invokeTauri("write_agent_config_field", { + request, + }); +} + +export async function putAgentSessionConfig( + pubkey: string, + payload: unknown, +): Promise { + return invokeTauri("put_agent_session_config", { pubkey, payload }); +} + type RawUpdateManagedAgentResponse = { agent: RawManagedAgent; profile_sync_error: string | null; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ef59385aa..4b058f87e 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -476,6 +476,101 @@ export type AgentModelInfo = { name: string | null; description: string | null; }; + +// ── Config bridge types ────────────────────────────────────────────────────── + +export type ConfigOrigin = + | "buzzExplicit" + | "acpNativeRead" + | "acpConfigOption" + | "envVar" + | "configFile" + | "personaDefault"; + +export type ConfigWriteMechanism = + | { type: "respawnWithEnvVar"; envKey: string } + | { type: "acpSetConfigOption"; configId: string } + | { type: "acpSetSessionModel" } + | { type: "gooseNativeConfigWrite"; configKey: string } + | { type: "readOnly" }; + +export type NormalizedField = { + value: string | null; + origin: ConfigOrigin; + isWritable: boolean; + writeVia: ConfigWriteMechanism; + overriddenValue: string | null; + overriddenOrigin: ConfigOrigin | null; +}; + +export type ConfigFieldType = + | { type: "string" } + | { type: "number" } + | { type: "boolean" } + | { type: "enum"; options: string[] }; + +export type ConfigField = { + key: string; + label: string; + value: string | null; + origin: ConfigOrigin; + schemaType: ConfigFieldType; + isWritable: boolean; + writeVia: ConfigWriteMechanism; +}; + +export type ConfigTierStatus = "available" | "pending" | "notApplicable"; + +export type ConfigSourceReport = { + acpNative: ConfigTierStatus; + acpConfigOptions: ConfigTierStatus; + envVars: ConfigTierStatus; + configFile: ConfigTierStatus; + configFilePath: string | null; +}; + +export type NormalizedConfig = { + model: NormalizedField | null; + provider: NormalizedField | null; + mode: NormalizedField | null; + thinkingEffort: NormalizedField | null; + maxOutputTokens: NormalizedField | null; + contextLimit: NormalizedField | null; + systemPrompt: NormalizedField | null; +}; + +export type RuntimeConfigSurface = { + runtimeId: string | null; + runtimeLabel: string | null; + isPreSpawn: boolean; + normalized: NormalizedConfig; + advanced: ConfigField[]; + sources: ConfigSourceReport; +}; + +export type WriteConfigTarget = + | { type: "model" } + | { type: "provider" } + | { type: "mode" } + | { type: "thinkingEffort" } + | { type: "maxOutputTokens" } + | { type: "contextLimit" } + | { type: "systemPrompt" } + | { type: "advanced"; key: string }; + +export type WriteConfigFieldRequest = { + pubkey: string; + field: WriteConfigTarget; + value: string | null; +}; + +export type WriteConfigResult = { + success: boolean; + mechanismUsed: ConfigWriteMechanism; + requiresRestart: boolean; + error: string | null; +}; + export type UpdateManagedAgentInput = { pubkey: string; name?: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ba2148d25..106eb938a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1000,6 +1000,304 @@ function resetMockRelayMembers(config: E2eConfig | undefined) { ]; } +function buildMockConfigSurface(pubkey: string): { + runtimeId: string | null; + runtimeLabel: string | null; + isPreSpawn: boolean; + normalized: Record; + advanced: unknown[]; + sources: Record; +} { + // Goose running — mixed origins, override on model + const gooseSurface = { + runtimeId: "goose", + runtimeLabel: "Goose", + isPreSpawn: false, + normalized: { + model: { + value: "gpt-4o", + origin: "buzzExplicit", + isWritable: true, + writeVia: { type: "acpSetSessionModel" }, + overriddenValue: "gpt-4o-mini", + overriddenOrigin: "configFile", + }, + provider: { + value: "openai", + origin: "configFile", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "auto", + origin: "envVar", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: "medium", + origin: "configFile", + isWritable: true, + writeVia: { + type: "gooseNativeConfigWrite", + configKey: "GOOSE_THINKING_EFFORT", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [ + { + key: "extensions.developer", + label: "Extension: developer", + value: "enabled", + origin: "configFile", + schemaType: { type: "enum", options: ["enabled", "disabled"] }, + isWritable: false, + writeVia: { type: "readOnly" }, + }, + { + key: "extensions.web_search", + label: "Extension: web_search", + value: "enabled", + origin: "configFile", + schemaType: { type: "enum", options: ["enabled", "disabled"] }, + isWritable: false, + writeVia: { type: "readOnly" }, + }, + { + key: "extensions.memory", + label: "Extension: memory", + value: "disabled", + origin: "configFile", + schemaType: { type: "enum", options: ["enabled", "disabled"] }, + isWritable: false, + writeVia: { type: "readOnly" }, + }, + ], + sources: { + acpNative: "available", + acpConfigOptions: "available", + envVars: "available", + configFile: "available", + configFilePath: "~/.config/goose/config.yaml", + }, + }; + + // Claude Code — mostly ACP-sourced + const claudeSurface = { + runtimeId: "claude-code", + runtimeLabel: "Claude Code", + isPreSpawn: false, + normalized: { + model: { + value: "claude-sonnet-4-20250514", + origin: "acpConfigOption", + isWritable: true, + writeVia: { type: "acpSetConfigOption", configId: "model" }, + overriddenValue: null, + overriddenOrigin: null, + }, + provider: { + value: "anthropic", + origin: "acpConfigOption", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "code", + origin: "acpConfigOption", + isWritable: true, + writeVia: { type: "acpSetConfigOption", configId: "mode" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: "high", + origin: "acpConfigOption", + isWritable: true, + writeVia: { + type: "acpSetConfigOption", + configId: "thinking_effort", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: { + value: "16384", + origin: "acpConfigOption", + isWritable: true, + writeVia: { + type: "acpSetConfigOption", + configId: "max_output_tokens", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + sources: { + acpNative: "available", + acpConfigOptions: "available", + envVars: "notApplicable", + configFile: "available", + configFilePath: "~/.claude/settings.json", + }, + }; + + // Pre-spawn — model from config file, ACP fields pending + const preSpawnSurface = { + runtimeId: "goose", + runtimeLabel: "Goose", + isPreSpawn: true, + normalized: { + model: { + value: "gpt-4o-mini", + origin: "configFile", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + provider: { + value: "openai", + origin: "configFile", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: null, + origin: "acpNativeRead", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: null, + origin: "acpNativeRead", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + sources: { + acpNative: "pending", + acpConfigOptions: "pending", + envVars: "available", + configFile: "available", + configFilePath: "~/.config/goose/config.yaml", + }, + }; + + // Codex — dual-axis mode + const codexSurface = { + runtimeId: "codex", + runtimeLabel: "Codex", + isPreSpawn: false, + normalized: { + model: { + value: "codex-mini", + origin: "configFile", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "CODEX_MODEL" }, + overriddenValue: null, + overriddenOrigin: null, + }, + provider: { + value: "openai", + origin: "configFile", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "suggest / auto-edit", + origin: "configFile", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "CODEX_MODE" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: null, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [ + { + key: "approval_policy", + label: "Approval Policy", + value: "unless-allow-listed", + origin: "configFile", + schemaType: { + type: "enum", + options: ["suggest", "auto-edit", "full-auto", "unless-allow-listed"], + }, + isWritable: false, + writeVia: { type: "readOnly" }, + }, + { + key: "sandbox_mode", + label: "Sandbox Mode", + value: "container", + origin: "envVar", + schemaType: { + type: "enum", + options: ["container", "host", "none"], + }, + isWritable: false, + writeVia: { type: "readOnly" }, + }, + ], + sources: { + acpNative: "notApplicable", + acpConfigOptions: "notApplicable", + envVars: "available", + configFile: "available", + configFilePath: "~/.codex/config.toml", + }, + }; + + // Map well-known test pubkeys to specific fixtures + const PUBKEY_CLAUDE = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + const PUBKEY_PRESPAWN = + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; + const PUBKEY_CODEX = + "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; + + switch (pubkey) { + case PUBKEY_CLAUDE: + return claudeSurface; + case PUBKEY_PRESPAWN: + return preSpawnSurface; + case PUBKEY_CODEX: + return codexSurface; + default: + return gooseSurface; + } +} + function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { const now = new Date().toISOString(); const status = seed.status ?? "stopped"; @@ -6725,6 +7023,10 @@ export function maybeInstallE2eTauriMocks() { selectedModel: null, supportsSwitching: false, }; + case "get_agent_config_surface": { + const configArgs = payload as { pubkey: string }; + return buildMockConfigSurface(configArgs.pubkey); + } case "update_managed_agent": return handleUpdateManagedAgent( payload as Parameters[0], diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts new file mode 100644 index 000000000..4c85b64bb --- /dev/null +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/config-bridge"; + +// Use well-known test pubkeys that map to distinct config surface fixtures +const GOOSE_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const CLAUDE_PUBKEY = TEST_IDENTITIES.alice.pubkey; +const PRESPAWN_PUBKEY = TEST_IDENTITIES.bob.pubkey; +const CODEX_PUBKEY = TEST_IDENTITIES.charlie.pubkey; + +const MANAGED_AGENTS = [ + { pubkey: GOOSE_PUBKEY, name: "Goose Agent", status: "running" as const }, + { + pubkey: CLAUDE_PUBKEY, + name: "Claude Code Agent", + status: "running" as const, + }, + { + pubkey: PRESPAWN_PUBKEY, + name: "Pre-Spawn Agent", + status: "stopped" as const, + }, + { pubkey: CODEX_PUBKEY, name: "Codex Agent", status: "running" as const }, +]; + +async function waitForInvokeBridge(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => { + const tauriWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + __TAURI_INTERNALS__?: { invoke?: unknown }; + }; + return ( + typeof tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" || + typeof tauriWindow.__TAURI_INTERNALS__?.invoke === "function" + ); + }, + null, + { timeout: 5_000 }, + ); +} + +async function invokeMockCommand( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +): Promise { + await waitForInvokeBridge(page); + return page.evaluate( + async ({ command: cmd, payload: pl }) => { + const tauriWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + const invoke = + tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? + tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!invoke) throw new Error("Mock invoke bridge is unavailable."); + return invoke(cmd, pl); + }, + { command, payload }, + ); +} + +async function activatePersonas(page: import("@playwright/test").Page) { + for (const id of ["builtin:fizz"]) { + await invokeMockCommand(page, "set_persona_active", { id, active: true }); + } +} + +async function openAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForInvokeBridge(page); + await activatePersonas(page); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-personas")).toBeVisible({ + timeout: 10_000, + }); +} + +async function expandAgent( + page: import("@playwright/test").Page, + pubkey: string, +) { + const agentRow = page.getByTestId(`managed-agent-${pubkey}`); + await expect(agentRow).toBeVisible({ timeout: 5_000 }); + // Click the expandable button within the agent row + await agentRow.locator("button").first().click(); + // Wait for the config panel to render (log row appears first, config is inside it) + await expect(agentRow.getByTestId("managed-agent-log-row")).toBeVisible({ + timeout: 5_000, + }); +} + +test.describe("config bridge screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01 — goose full config panel", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, GOOSE_PUBKEY); + + const logRow = page + .getByTestId(`managed-agent-${GOOSE_PUBKEY}`) + .getByTestId("managed-agent-log-row"); + await logRow.screenshot({ path: `${SHOTS}/01-goose-full-config.png` }); + }); + + test("02 — claude ACP config", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, CLAUDE_PUBKEY); + + const logRow = page + .getByTestId(`managed-agent-${CLAUDE_PUBKEY}`) + .getByTestId("managed-agent-log-row"); + await logRow.screenshot({ path: `${SHOTS}/02-claude-acp-config.png` }); + }); + + test("03 — pre-spawn state", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, PRESPAWN_PUBKEY); + + const logRow = page + .getByTestId(`managed-agent-${PRESPAWN_PUBKEY}`) + .getByTestId("managed-agent-log-row"); + await logRow.screenshot({ path: `${SHOTS}/03-pre-spawn-state.png` }); + }); + + test("04 — override visibility", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, GOOSE_PUBKEY); + + // The goose fixture has model overridden from configFile by buzzExplicit. + // Capture the config section (below the log content). + const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); + const configSection = agentRow.locator("text=Configuration").locator(".."); + await configSection.screenshot({ + path: `${SHOTS}/04-override-visibility.png`, + }); + }); + + test("05 — advanced section expanded", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, GOOSE_PUBKEY); + + // Click the Advanced chevron button + const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); + const advancedButton = agentRow.getByRole("button", { name: /Advanced/i }); + await advancedButton.click(); + + // Wait for advanced fields to appear + await expect(agentRow.locator("text=Extension: developer")).toBeVisible(); + + const logRow = agentRow.getByTestId("managed-agent-log-row"); + await logRow.screenshot({ path: `${SHOTS}/05-advanced-expanded.png` }); + }); + + test("06 — sources footer", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, GOOSE_PUBKEY); + + // The sources footer shows tier status indicators + const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); + const sourcesFooter = agentRow + .locator("p") + .filter({ hasText: "Config file" }); + await expect(sourcesFooter).toBeVisible(); + await sourcesFooter.screenshot({ path: `${SHOTS}/06-sources-footer.png` }); + }); + + test("07 — codex dual mode", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, CODEX_PUBKEY); + + const logRow = page + .getByTestId(`managed-agent-${CODEX_PUBKEY}`) + .getByTestId("managed-agent-log-row"); + await logRow.screenshot({ path: `${SHOTS}/07-codex-dual-mode.png` }); + }); +}); From a2a1ee9d7c5ce88019008dd891954f1ddad52e74 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 16 Jun 2026 19:42:16 -0400 Subject: [PATCH 02/14] feat(config-bridge): resolve persona config in a shared helper and replace badges with provenance sentences Persona-linked agents had their inherited model/prompt/provider invisible in the config panel, and the source indicators (checkmark/hourglass icons, colored badges, footer) were undecipherable. Phase 1: Resolve all three persona fields (prompt, model, provider) in a single resolve_config_surface helper called by both get_agent_config_surface (read) and write_agent_config_field (write). The helper injects resolved values into the record where absent, calls the reader, then re-tags the injected fields from BuzzExplicit to PersonaDefault. The re-tag is triple- gated so a value the user set explicitly in Buzz is never re-tagged. Sharing the helper keeps the read and write surfaces identical, so plan_config_write never returns "field not available" for a persona-sourced field. Reader stays untouched (pure tier-merge function). Phase 2: Add AgentConfigPanel to ProfileSummaryView in the profile pop-out, gated on isBot && isOwner && managedAgent defined. Phase 3: Remove SourcesFooter and colored OriginBadge pills. Replace with gray inline provenance sentences below each value ("Set in Buzz", "Inherited from persona", "From environment variable", etc). No action clauses. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/agent_config.rs | 234 +++++++++++++++++- .../managed_agents/config_bridge/reader.rs | 117 +++++++++ .../src/managed_agents/config_bridge/types.rs | 5 +- .../features/agents/ui/AgentConfigPanel.tsx | 159 +++--------- .../profile/ui/UserProfilePanelSections.tsx | 19 ++ .../e2e/config-bridge-screenshots.spec.ts | 21 +- 6 files changed, 416 insertions(+), 139 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 554a53672..d0b377aee 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -6,20 +6,100 @@ use crate::{ config_bridge::{ reader::read_config_surface, types::{ - AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigWriteMechanism, - RuntimeConfigSurface, SessionConfigCache, WriteConfigFieldRequest, - WriteConfigResult, WriteConfigTarget, + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, + ConfigWriteMechanism, NormalizedField, RuntimeConfigSurface, SessionConfigCache, + WriteConfigFieldRequest, WriteConfigResult, WriteConfigTarget, }, writer::plan_config_write, }, - known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes, + known_acp_runtime, load_managed_agents, load_personas, + resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, + KnownAcpRuntime, ManagedAgentRecord, PersonaRecord, }, }; +/// Resolve the config surface with persona values applied. +/// +/// Both the read path (`get_agent_config_surface`) and the write path +/// (`write_agent_config_field`) must see the same surface, so this is the +/// single place persona resolution happens. The pipeline: resolve the linked +/// persona's prompt/model/provider, inject each into the record only where the +/// record lacks its own value, let `read_config_surface` tag those injected +/// fields `BuzzExplicit`, then re-tag exactly the injected fields to +/// `PersonaDefault`. +/// +/// The re-tag is triple-gated — a field is re-tagged only when (a) the record +/// did not already have it (`!had_*`), (b) the surface produced the field, and +/// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in +/// Buzz keeps `had_* == true` and is never re-tagged. +fn resolve_config_surface( + mut record: ManagedAgentRecord, + personas: &[PersonaRecord], + runtime_meta: Option<&KnownAcpRuntime>, + session_cache: Option<&SessionConfigCache>, +) -> RuntimeConfigSurface { + let had_prompt = + record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); + let had_model = record.model.is_some(); + + let provider_env_key = runtime_meta.and_then(|m| m.provider_env_var).unwrap_or(""); + let had_provider = record.env_vars.contains_key(provider_env_key); + + let (persona_prompt, persona_model, persona_provider) = resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + personas, + record.system_prompt.clone(), + record.model.clone(), + ); + + // Inject resolved persona values into the record where absent. + if !had_prompt { + if let Some(p) = persona_prompt { + record + .env_vars + .insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p); + } + } + if !had_model { + record.model = persona_model; + } + if !had_provider && !provider_env_key.is_empty() { + if let Some(prov) = persona_provider { + record.env_vars.insert(provider_env_key.to_string(), prov); + } + } + + let mut surface = read_config_surface(&record, runtime_meta, session_cache); + + // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. + if !had_prompt { + retag_persona_default(&mut surface.normalized.system_prompt); + } + if !had_model { + retag_persona_default(&mut surface.normalized.model); + } + if !had_provider && !provider_env_key.is_empty() { + retag_persona_default(&mut surface.normalized.provider); + } + + surface +} + +/// Re-tag a field's origin from `BuzzExplicit` to `PersonaDefault`, leaving any +/// other origin untouched. No-op when the field is absent. +fn retag_persona_default(field: &mut Option) { + if let Some(field) = field { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } +} + /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. /// Pre-spawn agents show config file values with ACP tiers marked as pending. +/// Persona-sourced values are resolved by `resolve_config_surface`. #[tauri::command] pub async fn get_agent_config_surface( pubkey: String, @@ -45,11 +125,13 @@ pub async fn get_agent_config_surface( .ok_or_else(|| format!("agent {pubkey} not found"))? }; + let personas = load_personas(&app).unwrap_or_default(); let runtime_meta = known_acp_runtime(&record.agent_command); let session_cache = state.get_session_cache(&pubkey); - Ok(read_config_surface( - &record, + Ok(resolve_config_surface( + record, + &personas, runtime_meta, session_cache.as_ref(), )) @@ -60,6 +142,10 @@ pub async fn get_agent_config_surface( /// Plans the write mechanism based on the current config surface, then /// executes: either updating the record (for env var respawn) or returning /// the mechanism for the frontend to send via observer control (for ACP writes). +/// +/// Uses the same persona-resolved surface as `get_agent_config_surface` so +/// `plan_config_write` sees persona-sourced fields and never returns +/// "field not available" for a value inherited from the linked persona. #[tauri::command] pub async fn write_agent_config_field( request: WriteConfigFieldRequest, @@ -78,9 +164,10 @@ pub async fn write_agent_config_field( .cloned() .ok_or_else(|| format!("agent {} not found", request.pubkey))?; + let personas = load_personas(&app).unwrap_or_default(); let runtime_meta = known_acp_runtime(&record.agent_command); let session_cache = state.get_session_cache(&request.pubkey); - let surface = read_config_surface(&record, runtime_meta, session_cache.as_ref()); + let surface = resolve_config_surface(record, &personas, runtime_meta, session_cache.as_ref()); let mut result = plan_config_write(&surface, &request.field); @@ -293,3 +380,136 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< .collect(); (models, current_model) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::{BackendKind, RespondTo}; + + fn goose_runtime() -> &'static KnownAcpRuntime { + &KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[], + 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"), + } + } + + fn agent_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "agent".to_string(), + name: "Agent".to_string(), + persona_id: Some("persona-1".to_string()), + 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, + mcp_toolsets: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + relay_mesh: None, + } + } + + fn persona_with_model(model: &str) -> PersonaRecord { + PersonaRecord { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: None, + model: Some(model.to_string()), + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + created_at: "".to_string(), + updated_at: "".to_string(), + } + } + + /// The write path must see a persona-inherited model. Without persona + /// resolution `surface.normalized.model` would be `None` and + /// `plan_config_write` would return "field not available for this runtime". + #[test] + fn write_path_sees_persona_sourced_model_field() { + let record = agent_record(); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + + let result = plan_config_write(&surface, &WriteConfigTarget::Model); + assert!(result.success, "write plan failed: {:?}", result.error); + assert!(matches!( + result.mechanism_used, + ConfigWriteMechanism::RespawnWithEnvVar { .. } + )); + } + + /// A model the user set explicitly in Buzz must never be re-tagged to + /// `PersonaDefault`, even when the linked persona also has a model. + #[test] + fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.model = Some("explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + } +} 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 46d57c7e9..8e0a949b6 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -579,4 +579,121 @@ mod tests { // the actual file in a unit test, just verify the override fields are populated // when we manually construct the scenario via build_model_field. } + + // ── Persona resolution integration tests ──────────────────────────── + // + // These simulate the call-site pattern in agent_config.rs: + // 1. Inject persona-resolved values into the record (as if absent) + // 2. Call read_config_surface (reader tags them BuzzExplicit) + // 3. Re-tag injected fields to PersonaDefault + // + // This exercises the same logic path as get_agent_config_surface without + // requiring Tauri AppHandle/State infrastructure. + + #[test] + fn persona_model_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no model, persona provides one. + // The call-site injects it before calling the reader. + record.model = Some("persona-model".to_string()); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None); + + // Reader sees injected model as BuzzExplicit. + let model = surface.normalized.model.as_ref().unwrap(); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_model == false). + if let Some(ref mut field) = surface.normalized.model { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn persona_provider_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no provider env var, persona provides one. + // The call-site injects it as GOOSE_PROVIDER before calling the reader. + record + .env_vars + .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None); + + // Reader sees injected provider as BuzzExplicit. + let provider = surface.normalized.provider.as_ref().unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_provider == false). + if let Some(ref mut field) = surface.normalized.provider { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let provider = surface.normalized.provider.unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn persona_system_prompt_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no system_prompt, persona provides one via env var. + // The call-site injects it as BUZZ_ACP_SYSTEM_PROMPT before calling the reader. + record.env_vars.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "You are a helpful assistant.".to_string(), + ); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None); + + // Reader sees injected prompt as BuzzExplicit. + let prompt = surface.normalized.system_prompt.as_ref().unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_prompt == false). + if let Some(ref mut field) = surface.normalized.system_prompt { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn explicit_record_model_not_retagged_when_already_present() { + let mut record = test_record(); + // Record already has its own model — persona resolution should NOT re-tag. + record.model = Some("explicit-model".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None); + + // had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit. + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 5324eec43..b176f109c 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -17,8 +17,9 @@ pub enum ConfigOrigin { /// Read from harness config file on disk (tier 2b, lowest precedence). ConfigFile, /// Value inherited from persona defaults. - /// Forward slot — not yet populated by any reader. Will be wired when - /// persona pack config resolution is added to `read_config_surface`. + /// Populated by the `get_agent_config_surface` call site: persona values are + /// resolved before calling the reader, then the surface is post-processed to + /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. PersonaDefault, } diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index decf3896d..636247b77 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronDown, ChevronRight, Info } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { useAgentConfigSurface } from "../hooks"; import { cn } from "@/shared/lib/cn"; @@ -7,9 +7,9 @@ import { Spinner } from "@/shared/ui/spinner"; import type { ConfigField, ConfigOrigin, + ConfigWriteMechanism, NormalizedConfig, NormalizedField, - ConfigSourceReport, } from "@/shared/api/types"; type Props = { @@ -17,68 +17,34 @@ type Props = { isRunning: boolean; }; -// ── Origin badge ───────────────────────────────────────────────────────────── +// ── Provenance sentence ────────────────────────────────────────────────────── -function originLabel( +function provenanceSentence( origin: ConfigOrigin, + writeVia: ConfigWriteMechanism, configFilePath: string | null, ): string { switch (origin) { case "buzzExplicit": - return "Buzz"; - case "acpConfigOption": - return "ACP"; - case "acpNativeRead": - return "ACP"; - case "envVar": - return "Env"; - case "configFile": { - if (configFilePath) { - const parts = configFilePath.split(/[/\\]/); - return parts[parts.length - 1] ?? configFilePath; + return "Set in Buzz"; + case "personaDefault": + return "Inherited from persona"; + case "envVar": { + if (writeVia.type === "respawnWithEnvVar") { + return `From environment variable (${writeVia.envKey})`; } - return "Config"; + return "From environment variable"; } - case "personaDefault": - return "Persona"; - } -} - -function originColorClass(origin: ConfigOrigin): string { - switch (origin) { - case "buzzExplicit": - return "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"; + case "configFile": + return configFilePath + ? `From config file (${configFilePath})` + : "From config file"; case "acpConfigOption": case "acpNativeRead": - return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"; - case "configFile": - case "personaDefault": - return "bg-muted text-muted-foreground"; - case "envVar": - return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"; + return "From ACP session"; } } -function OriginBadge({ - origin, - configFilePath, -}: { - origin: ConfigOrigin; - configFilePath: string | null; -}) { - return ( - - {originLabel(origin, configFilePath)} - - ); -} - // ── Normalized row ──────────────────────────────────────────────────────────── const NORMALIZED_LABELS: Record = { @@ -107,19 +73,13 @@ function NormalizedRow({ field.origin === "acpNativeRead" || field.origin === "acpConfigOption"; return ( -
- - {label} - - - {/* Value area: effective value + strikethrough overridden value */} - +
+
{label}
+
{isPreSpawn && isAcpOnly ? ( - + Available after agent starts - - ) : isPreSpawn && field.origin === "configFile" && !field.value ? ( - + ) : ( <> {field.value ?? } @@ -130,25 +90,11 @@ function NormalizedRow({ )} )} - - - {/* Badge area: effective badge + strikethrough overridden badge */} - - - {field.overriddenOrigin && ( - - - - )} - - - {!field.isWritable && ( - - - +
+ {field.value && ( +
+ {provenanceSentence(field.origin, field.writeVia, configFilePath)} +
)}
); @@ -164,53 +110,22 @@ function AdvancedRow({ configFilePath: string | null; }) { return ( -
- - {field.label} - - +
+
{field.label}
+
{field.value ?? ( - + )} - - - {!field.isWritable && ( - - - +
+ {field.value && ( +
+ {provenanceSentence(field.origin, field.writeVia, configFilePath)} +
)}
); } -// ── Sources footer ──────────────────────────────────────────────────────────── - -const STATUS_ICON: Record = { - available: "✓", - pending: "⏳", - notApplicable: "—", -}; - -function SourcesFooter({ sources }: { sources: ConfigSourceReport }) { - const tiers = [ - { label: "Config file", status: sources.configFile }, - { label: "ACP native", status: sources.acpNative }, - { label: "ACP config", status: sources.acpConfigOptions }, - { label: "Env vars", status: sources.envVars }, - ] as const; - - return ( -

- {tiers.map((tier, i) => ( - - {i > 0 && |} - {tier.label} {STATUS_ICON[tier.status] ?? tier.status} - - ))} -

- ); -} - // ── Main component ──────────────────────────────────────────────────────────── export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) { @@ -302,8 +217,6 @@ export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) { )}
)} - -
); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 42a3739c3..0f4f89d09 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -14,6 +14,7 @@ import { MessageSquare, Pencil, Server, + Settings, Terminal, UserMinus, UserPlus, @@ -22,6 +23,7 @@ import { import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; +import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -243,6 +245,23 @@ export function ProfileSummaryView({ {metadataFields.length > 0 ? ( ) : null} + + {isBot && isOwner === true && managedAgent !== undefined ? ( +
+
+ +

+ Configuration +

+
+
+ +
+
+ ) : null} ); } diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts index 4c85b64bb..502aecc5f 100644 --- a/desktop/tests/e2e/config-bridge-screenshots.spec.ts +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -169,18 +169,25 @@ test.describe("config bridge screenshots", () => { await logRow.screenshot({ path: `${SHOTS}/05-advanced-expanded.png` }); }); - test("06 — sources footer", async ({ page }) => { + test("06 — config provenance", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); await openAgentsView(page); await expandAgent(page, GOOSE_PUBKEY); - // The sources footer shows tier status indicators + // Each config value carries an inline provenance sentence below it. + // The goose fixture's model is buzzExplicit ("Set in Buzz"); its provider + // and thinking effort are configFile, so multiple rows render the same + // "From config file (...)" sentence — assert the first match. const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); - const sourcesFooter = agentRow - .locator("p") - .filter({ hasText: "Config file" }); - await expect(sourcesFooter).toBeVisible(); - await sourcesFooter.screenshot({ path: `${SHOTS}/06-sources-footer.png` }); + await expect(agentRow.getByText("Set in Buzz")).toBeVisible(); + await expect( + agentRow + .getByText("From config file (~/.config/goose/config.yaml)") + .first(), + ).toBeVisible(); + + const logRow = agentRow.getByTestId("managed-agent-log-row"); + await logRow.screenshot({ path: `${SHOTS}/06-config-provenance.png` }); }); test("07 — codex dual mode", async ({ page }) => { From 27511a07e62786109ff2fcc66d9ecb9fc71c5352 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 14:20:21 -0400 Subject: [PATCH 03/14] feat(config-bridge): live model override and folded config panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a model on a persona-linked running agent now applies to the live instance instead of forcing a restart. The switch rides the existing `desired_model` lever plus the Interrupt requeue/invalidate machinery: a busy turn cancel-switch-requeues onto a fresh session under the new model; an idle session invalidates and re-applies on its next turn. The override is runtime-only — never persisted to `record.model`, gone on restart/respawn, so spawn resolution stays persona-wins. `ControlSignal` drops `Copy` to carry the owned model id; the post-match re-read is replaced by a `requeues()` predicate. A model absent from the agent's catalog surfaces an `unsupported_model` control_result (idle path guards pre-cancel; busy path validates on the re-created session post-cancel) so the picker rejects rather than silently no-opping. The desktop keys the override-active display off the ACP `current_model` diverging from the persona model (the harness-only `desired_model` is unreadable by the reader), shows the persona as a non-struck secondary, and folds the standalone Configuration block into the metadata card. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/acp.rs | 87 +++++++++ crates/buzz-acp/src/lib.rs | 103 +++++++++- crates/buzz-acp/src/pool.rs | 184 +++++++++++++++--- .../src-tauri/src/commands/agent_config.rs | 17 +- .../managed_agents/config_bridge/reader.rs | 168 +++++++++++++--- .../src/managed_agents/config_bridge/types.rs | 5 + .../src/features/agents/observerRelayStore.ts | 58 ++++++ .../features/agents/ui/AgentConfigPanel.tsx | 12 +- .../src/features/agents/ui/ModelPicker.tsx | 71 ++++++- .../profile/ui/UserProfilePanelSections.tsx | 46 +++-- desktop/src/shared/api/agentControl.ts | 18 ++ desktop/src/shared/api/types.ts | 22 ++- 12 files changed, 713 insertions(+), 78 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 6cd705d6c..88a072f6f 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1215,6 +1215,41 @@ pub fn resolve_model_switch_method( None } +/// Whether `desired_model` appears in pre-extracted catalog halves. +/// +/// Mirrors [`resolve_model_switch_method`]'s match, but operates on the +/// already-extracted `configOptions` (model category) and `models` state that +/// [`AgentModelCapabilities`](crate::pool::AgentModelCapabilities) caches — the +/// idle-path pre-cancel guard has those halves, not the full `session/new` JSON. +pub fn model_in_catalog( + config_options: &[serde_json::Value], + available_models: Option<&serde_json::Value>, + desired_model: &str, +) -> bool { + let in_config_options = config_options.iter().any(|config_opt| { + config_opt + .get("options") + .and_then(|v| v.as_array()) + .is_some_and(|options| { + options + .iter() + .any(|opt| opt.get("value").and_then(|v| v.as_str()) == Some(desired_model)) + }) + }); + if in_config_options { + return true; + } + + available_models + .and_then(|models| models.get("availableModels")) + .and_then(|v| v.as_array()) + .is_some_and(|available| { + available + .iter() + .any(|model| model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model)) + }) +} + // ─── Drop: kill child process ───────────────────────────────────────────────── impl Drop for AcpClient { @@ -1783,6 +1818,58 @@ mod tests { ); } + // ── model_in_catalog tests ──────────────────────────────────────────── + + #[test] + fn model_in_catalog_true_when_in_config_options() { + let config_options = vec![serde_json::json!({ + "configId": "model", + "category": "model", + "options": [ + { "value": "claude-sonnet-4-20250514" }, + { "value": "claude-opus-4-20250514" } + ] + })]; + assert!(super::model_in_catalog( + &config_options, + None, + "claude-opus-4-20250514" + )); + } + + #[test] + fn model_in_catalog_true_when_in_available_models() { + let available = serde_json::json!({ + "currentModelId": "gpt-5", + "availableModels": [ + { "modelId": "gpt-5" }, + { "modelId": "o3-pro" } + ] + }); + assert!(super::model_in_catalog(&[], Some(&available), "o3-pro")); + } + + #[test] + fn model_in_catalog_false_when_absent_from_both_halves() { + let config_options = vec![serde_json::json!({ + "configId": "model", + "options": [{ "value": "claude-sonnet-4-20250514" }] + })]; + let available = serde_json::json!({ + "availableModels": [{ "modelId": "gpt-5" }] + }); + assert!(!super::model_in_catalog( + &config_options, + Some(&available), + "nonexistent-model" + )); + } + + #[test] + fn model_in_catalog_false_when_both_halves_empty() { + assert!(!super::model_in_catalog(&[], None, "anything")); + } + // ── Error variant display ───────────────────────────────────────────── #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 5da767bc5..aab86972b 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -29,8 +29,8 @@ use filter::SubscriptionRule; use futures_util::FutureExt; use nostr::{PublicKey, ToBech32}; use pool::{ - AgentPool, ControlSignal, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, - SessionState, + AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome, + PromptResult, PromptSource, SessionState, }; use queue::{EventQueue, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; @@ -718,11 +718,25 @@ fn handle_relay_observer_control_event( }; let command_type = payload.get("type").and_then(|value| value.as_str()); - if command_type != Some("cancel_turn") { - tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); - return; + match command_type { + Some("cancel_turn") => { + handle_cancel_turn_control(&payload, pool, observer); + } + Some("switch_model") => { + handle_switch_model_control(&payload, pool, observer); + } + _ => { + tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); + } } +} +/// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. +fn handle_cancel_turn_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { let Some(channel_id) = payload .get("channelId") .and_then(|value| value.as_str()) @@ -751,6 +765,83 @@ fn handle_relay_observer_control_event( } } +/// Handle a `switch_model` control frame (Phase 3a, Option ii). +/// +/// Busy path: deliver `SwitchModel` over the in-flight task's oneshot — the +/// task cancels the turn, sets `desired_model`, and requeues the batch so it +/// re-runs on a fresh session under the new model. A catalog miss surfaces +/// post-cancel via `create_session_and_apply_model` (the turn restarts on the +/// unchanged model + an `unsupported_model` result). +/// +/// Idle path: validate against the cached catalog *before* invalidating +/// (pre-cancel guard), then set `desired_model` + invalidate. The override +/// takes visible effect on the agent's next turn. +fn handle_switch_model_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { + let Some(channel_id) = payload + .get("channelId") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()) + else { + tracing::warn!("observer switch_model control frame missing valid channelId"); + return; + }; + let Some(model_id) = payload.get("modelId").and_then(|value| value.as_str()) else { + tracing::warn!("observer switch_model control frame missing modelId"); + return; + }; + + // A turn is in flight for this channel iff a task_map entry exists. The + // agent is moved out of the pool during a turn, so the control oneshot is + // the only reachable lever; an idle channel has no such entry. + let turn_in_flight = pool + .task_map() + .values() + .any(|m| m.channel_id == Some(channel_id)); + + let status = if turn_in_flight { + // Busy path: deliver over the oneshot. `false` means the oneshot was + // already consumed this turn (a prior cancel/interrupt) — the turn is + // already ending, so the switch cannot land on it. + if signal_in_flight_task( + pool, + channel_id, + ControlSignal::SwitchModel(model_id.to_string()), + ) { + "sent" + } else { + "turn_ending" + } + } else { + // Idle path: validate against the cached catalog before invalidating. + match pool.switch_idle_agent_model(channel_id, model_id) { + IdleSwitchResult::Switched => "switched", + IdleSwitchResult::UnsupportedModel => "unsupported_model", + IdleSwitchResult::NoIdleAgent => "no_active_turn", + } + }; + + if let Some(observer) = observer { + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: Some(channel_id.to_string()), + session_id: None, + turn_id: None, + }, + serde_json::json!({ + "type": "switch_model", + "status": status, + "modelId": model_id, + }), + ); + } +} + /// Maximum crashes in a 60-second window before a slot's circuit opens. const CIRCUIT_BREAKER_THRESHOLD: usize = 3; /// Window for circuit-breaker crash counting. @@ -2161,8 +2252,8 @@ fn signal_in_flight_task( if let Some(meta) = entry { if let Some(tx) = meta.control_tx.take() { - let _ = tx.send(mode); tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task"); + let _ = tx.send(mode); return true; } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 3be7b75ef..ae8560beb 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -29,8 +29,8 @@ use tokio::time::timeout; use uuid::Uuid; use crate::acp::{ - extract_model_config_options, extract_model_state, resolve_model_switch_method, AcpClient, - AcpError, McpServer, ModelSwitchMethod, StopReason, + extract_model_config_options, extract_model_state, model_in_catalog, + resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, }; use crate::config::{DedupMode, PermissionMode}; use crate::observer; @@ -176,15 +176,24 @@ pub enum PromptSource { fn apply_completed_before_control_signal( state: &mut SessionState, source: &PromptSource, - control_signal: ControlSignal, + control_signal: &ControlSignal, ) { - if control_signal == ControlSignal::Rotate { + // Rotate and SwitchModel both invalidate so the next turn creates a fresh + // session. For SwitchModel the caller has already set `desired_model`, so + // the fresh session applies the new model on its next creation. + if matches!( + control_signal, + ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ) { state.invalidate(source); } } /// Control signal for an in-flight channel turn. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when +/// a value is needed after a move, or match by reference. +#[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. Cancel, @@ -193,6 +202,23 @@ pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. The session is /// invalidated just like cancel; the next turn creates a fresh session. Rotate, + /// Switch the agent's model, then requeue the triggering batch so it + /// re-runs on a fresh session under the new model. The model lands by + /// setting `OwnedAgent::desired_model` before invalidation; the requeued + /// turn re-creates the session and re-applies `desired_model`. Runtime-only + /// — never persisted, gone on restart/respawn. + SwitchModel(String), +} + +impl ControlSignal { + /// Whether this signal requeues its triggering batch (vs dropping it). + /// `Interrupt` and `SwitchModel` requeue; `Cancel`/`Rotate` drop. + fn requeues(&self) -> bool { + matches!( + self, + ControlSignal::Interrupt | ControlSignal::SwitchModel(_) + ) + } } /// Outcome of a prompt task. @@ -403,9 +429,63 @@ impl AgentPool { } count } + + /// Idle-path model switch: set `desired_model` on the idle agent for + /// `channel_id` and invalidate its session so the next turn re-creates the + /// session under the new model. + /// + /// Pre-cancel guard: the desired model is validated against the agent's + /// cached catalog *before* the session is invalidated, so an unsupported + /// pick is rejected without disturbing the existing session. + /// + /// Returns [`IdleSwitchResult`] describing what happened. The model does not + /// take effect — and the panel does not reflect it — until the agent next + /// runs a turn (no live session exists to re-emit `session_config_captured` + /// from an idle agent). This lag is intentional: faking the emit would + /// surface an override the session has not actually applied. + pub fn switch_idle_agent_model( + &mut self, + channel_id: Uuid, + model_id: &str, + ) -> IdleSwitchResult { + let Some(agent) = self + .agents + .iter_mut() + .flatten() + .find(|a| a.state.sessions.contains_key(&channel_id)) + else { + return IdleSwitchResult::NoIdleAgent; + }; + + // Pre-cancel guard against the cached catalog. None = catalog not yet + // populated (no session ever created); defer validation to apply time. + if let Some(caps) = agent.model_capabilities.as_ref() { + if !model_in_catalog( + &caps.config_options_raw, + caps.available_models_raw.as_ref(), + model_id, + ) { + return IdleSwitchResult::UnsupportedModel; + } + } + + agent.desired_model = Some(model_id.to_string()); + agent.state.invalidate_channel(&channel_id); + IdleSwitchResult::Switched + } } -// ── run_prompt_task ─────────────────────────────────────────────────────────── +/// Outcome of [`AgentPool::switch_idle_agent_model`]. +#[derive(Debug, PartialEq, Eq)] +pub enum IdleSwitchResult { + /// `desired_model` set and the channel session invalidated. + Switched, + /// Desired model is not in the agent's cached catalog — pick rejected, + /// session untouched. + UnsupportedModel, + /// No idle agent available (all checked out / none spawned). + NoIdleAgent, +} /// Timeout for a single pre-prompt context fetch attempt (thread/DM history). /// Each call gets this budget; with one retry the total worst-case is @@ -485,6 +565,18 @@ async fn create_session_and_apply_model( target: "pool::model", "desired model {desired} not found in agent's available models — proceeding with agent default" ); + // Surface the miss so the desktop ModelPicker can reject a live + // pick rather than silently no-op. On the busy path the turn has + // already been cancelled+requeued by the time we get here, so the + // turn restarts on the unchanged model and the user is told no. + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "unsupported_model", + "modelId": desired, + }), + ); } } } @@ -1234,6 +1326,13 @@ pub async fn run_prompt_task( _ = &mut liveness => unreachable!("liveness future never resolves"), mode = rx => { let control_signal = mode.unwrap_or(ControlSignal::Cancel); + // Land the model switch before any cancel/requeue work: setting + // `desired_model` here means the fresh session created by the + // requeued turn (busy) or the next turn (already-completed) + // applies the new model. Runtime-only — never persisted. + if let ControlSignal::SwitchModel(ref model_id) = control_signal { + agent.desired_model = Some(model_id.clone()); + } // Control signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. if agent.acp.has_in_flight_prompt() { @@ -1249,9 +1348,10 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); agent.state.invalidate(&source); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1263,9 +1363,10 @@ pub async fn run_prompt_task( } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1278,9 +1379,10 @@ pub async fn run_prompt_task( Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout) => { // Cancel drain timed out — agent state uncertain. agent.state.invalidate(&source); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1292,9 +1394,10 @@ pub async fn run_prompt_task( } Err(e) => { agent.state.invalidate(&source); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1320,10 +1423,13 @@ pub async fn run_prompt_task( // and last_prompt_id was cleared by the success path. // // MUST send a PromptResult or the main loop deadlocks. - if control_signal == ControlSignal::Rotate { + if matches!( + control_signal, + ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ) { tracing::debug!( target: "pool::prompt", - "rotate signal arrived but turn already completed — invalidating session" + "rotate/switch signal arrived but turn already completed — invalidating session" ); } else { tracing::debug!( @@ -1334,7 +1440,7 @@ pub async fn run_prompt_task( apply_completed_before_control_signal( &mut agent.state, &source, - control_signal, + &control_signal, ); let _ = result_tx.send(PromptResult { agent, @@ -2914,7 +3020,7 @@ mod tests { apply_completed_before_control_signal( &mut s, &PromptSource::Channel(ch_a), - ControlSignal::Rotate, + &ControlSignal::Rotate, ); assert!(!s.sessions.contains_key(&ch_a)); @@ -2935,7 +3041,7 @@ mod tests { apply_completed_before_control_signal( &mut s, &PromptSource::Channel(ch_a), - ControlSignal::Cancel, + &ControlSignal::Cancel, ); assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a"); @@ -3058,6 +3164,38 @@ mod tests { assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); } + // ── ControlSignal::SwitchModel (Phase 3a, Option ii) ───────────────────── + + #[test] + fn test_switch_model_after_natural_completion_invalidates_channel_state() { + let (mut s, ch_a, ch_b) = make_state(); + + // SwitchModel must invalidate just like Rotate so the requeued turn + // re-creates a fresh session that re-applies the new desired_model. + apply_completed_before_control_signal( + &mut s, + &PromptSource::Channel(ch_a), + &ControlSignal::SwitchModel("gpt-5".into()), + ); + + assert!(!s.has_channel_state(&ch_a)); + // ch_b untouched — the switch is channel-scoped. + assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); + } + + #[test] + fn test_requeues_true_for_interrupt_and_switch_model() { + assert!(ControlSignal::Interrupt.requeues()); + assert!(ControlSignal::SwitchModel("any".into()).requeues()); + } + + #[test] + fn test_requeues_false_for_cancel_and_rotate() { + assert!(!ControlSignal::Cancel.requeues()); + assert!(!ControlSignal::Rotate.requeues()); + } + // ── turn liveness emission ─────────────────────────────────────────────── // `run_turn_liveness` is raced against a "prompt" future the same way // `run_prompt_task` does it: the prompt wins the select and the liveness diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index d0b377aee..c5694ffa4 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -52,6 +52,16 @@ fn resolve_config_surface( record.model.clone(), ); + // Retain the persona model so the reader can detect a live runtime override + // (ACP current model diverging from the persona model). Only meaningful when + // the record had no explicit model — an explicit pick is the user's choice, + // not a persona baseline to override. + let persona_model_for_override = if had_model { + None + } else { + persona_model.clone() + }; + // Inject resolved persona values into the record where absent. if !had_prompt { if let Some(p) = persona_prompt { @@ -69,7 +79,12 @@ fn resolve_config_surface( } } - let mut surface = read_config_surface(&record, runtime_meta, session_cache); + let mut surface = read_config_surface( + &record, + runtime_meta, + session_cache, + persona_model_for_override.as_deref(), + ); // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. if !had_prompt { 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 8e0a949b6..8159a6a43 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -11,6 +11,7 @@ pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, + persona_model: Option<&str>, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -49,14 +50,18 @@ pub(crate) fn read_config_surface( .cloned(); let normalized = NormalizedConfig { - model: Some(build_model_field( - &record_model, - &file_config.model, - &acp_model, - model_env_var, - supports_acp_model, - is_pre_spawn, - session_cache, + model: Some(apply_runtime_override( + build_model_field( + &record_model, + &file_config.model, + &acp_model, + model_env_var, + supports_acp_model, + is_pre_spawn, + session_cache, + ), + acp_model.as_deref(), + persona_model, )), provider: build_provider_field( &record_provider, @@ -210,8 +215,32 @@ fn build_model_field( None }; - // Write mechanism: prefer ACP if post-spawn and supported. - let write_via = if !is_pre_spawn && has_config_option(session_cache, "model") { + let write_via = model_write_mechanism( + is_pre_spawn, + supports_acp_model, + session_cache, + model_env_var, + ); + + NormalizedField { + value, + origin, + is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), + write_via, + overridden_value, + overridden_origin, + } +} + +/// Resolve how the model field is written back to the runtime. +/// Prefer ACP `set_config_option`/`set_model` post-spawn, else env-var respawn. +fn model_write_mechanism( + is_pre_spawn: bool, + supports_acp_model: bool, + session_cache: Option<&SessionConfigCache>, + model_env_var: Option<&str>, +) -> ConfigWriteMechanism { + if !is_pre_spawn && has_config_option(session_cache, "model") { let config_id = find_model_config_id(session_cache).unwrap_or_else(|| "model".to_string()); ConfigWriteMechanism::AcpSetConfigOption { config_id } } else if !is_pre_spawn && supports_acp_model { @@ -222,15 +251,38 @@ fn build_model_field( } } else { ConfigWriteMechanism::ReadOnly - }; + } +} +/// Re-key the model field as a live runtime override when the ACP session's +/// current model diverges from the persona model (Phase 3c). +/// +/// The override-active signal is `acp_model != persona_model` — NOT the +/// harness-only `desired_model`, which the desktop reader cannot see. A +/// persona-linked agent spawns with its ACP current model equal to the persona +/// model, so divergence only happens after a live ModelPicker switch. +/// +/// `persona_model` is `Some` only for persona-linked agents with no explicit +/// record model; an explicit pick has no persona baseline to override, so the +/// field passes through unchanged. The override preserves the base field's +/// write mechanism — only the displayed value, origin, and secondary change. +fn apply_runtime_override( + base: NormalizedField, + acp_model: Option<&str>, + persona_model: Option<&str>, +) -> NormalizedField { + let (Some(acp), Some(persona)) = (acp_model, persona_model) else { + return base; + }; + if acp == persona { + return base; + } NormalizedField { - value, - origin, - is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), - write_via, - overridden_value, - overridden_origin, + value: Some(acp.to_string()), + origin: ConfigOrigin::RuntimeOverride, + overridden_value: Some(persona.to_string()), + overridden_origin: Some(ConfigOrigin::PersonaDefault), + ..base } } @@ -493,7 +545,7 @@ mod tests { fn pre_spawn_surface_reports_pending_acp_tiers() { let record = test_record(); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None); + let surface = read_config_surface(&record, Some(runtime), None, None); assert!(surface.is_pre_spawn); assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); @@ -510,7 +562,7 @@ mod tests { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None); + let surface = read_config_surface(&record, Some(runtime), None, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -523,7 +575,7 @@ mod tests { provider_locked: true, ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None); + let surface = read_config_surface(&record, Some(runtime), None, None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); assert!(!provider.is_writable); @@ -548,7 +600,7 @@ mod tests { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache)); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); assert!(!surface.is_pre_spawn); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("claude-opus-4")); @@ -571,7 +623,7 @@ mod tests { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache)); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("acp-model")); assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); @@ -598,7 +650,7 @@ mod tests { record.model = Some("persona-model".to_string()); let runtime = test_runtime(); - let mut surface = read_config_surface(&record, Some(runtime), None); + let mut surface = read_config_surface(&record, Some(runtime), None, None); // Reader sees injected model as BuzzExplicit. let model = surface.normalized.model.as_ref().unwrap(); @@ -617,6 +669,70 @@ mod tests { assert_eq!(model.origin, ConfigOrigin::PersonaDefault); } + // ── Runtime override (Phase 3c) ────────────────────────────────────── + // + // A live ModelPicker switch surfaces as the ACP session's `current_model` + // diverging from the persona model. The reader keys the override-active + // decision off that divergence (acp_model != persona_model), NOT off the + // harness-only `desired_model` (which the desktop reader cannot see). + + #[test] + fn runtime_override_wins_display_when_acp_model_diverges_from_persona() { + // Persona-linked agent (record.model == None); persona == "persona-model". + // A live switch pushed "live-model" to the session. + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("live-model".to_string()), + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = + read_config_surface(&record, Some(runtime), Some(&cache), Some("persona-model")); + let model = surface.normalized.model.unwrap(); + + // Override wins the display value with a runtime-override origin. + assert_eq!(model.value.as_deref(), Some("live-model")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + // Persona is the secondary value (not struck through — the UI keys off + // the RuntimeOverride origin to suppress strikethrough). + assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } + + #[test] + fn no_runtime_override_when_acp_model_equals_persona() { + // At spawn the session's current_model == persona model (BUZZ_ACP_MODEL + // is set to the persona model). No divergence => no override; the field + // falls through to normal precedence (persona-wins surface, no override + // origin and no overridden secondary value). + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("persona-model".to_string()), + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = + read_config_surface(&record, Some(runtime), Some(&cache), Some("persona-model")); + let model = surface.normalized.model.unwrap(); + + // No divergence => the override branch is not taken: origin is the + // normal precedence result, never RuntimeOverride, and the persona is + // not surfaced as a secondary override value. + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } + #[test] fn persona_provider_injection_produces_persona_default_origin() { let mut record = test_record(); @@ -627,7 +743,7 @@ mod tests { .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); let runtime = test_runtime(); - let mut surface = read_config_surface(&record, Some(runtime), None); + let mut surface = read_config_surface(&record, Some(runtime), None, None); // Reader sees injected provider as BuzzExplicit. let provider = surface.normalized.provider.as_ref().unwrap(); @@ -657,7 +773,7 @@ mod tests { ); let runtime = test_runtime(); - let mut surface = read_config_surface(&record, Some(runtime), None); + let mut surface = read_config_surface(&record, Some(runtime), None, None); // Reader sees injected prompt as BuzzExplicit. let prompt = surface.normalized.system_prompt.as_ref().unwrap(); @@ -689,7 +805,7 @@ mod tests { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None); + let surface = read_config_surface(&record, Some(runtime), None, None); // had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit. let model = surface.normalized.model.unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index b176f109c..3afe5760f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -21,6 +21,11 @@ pub enum ConfigOrigin { /// resolved before calling the reader, then the surface is post-processed to /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. PersonaDefault, + /// Live runtime model override applied via the ModelPicker (Phase 3). + /// The ACP session's current model diverges from the persona model because + /// the user picked a different model on the running instance. Runtime-only — + /// never persisted; reverts to the persona model on restart/respawn. + RuntimeOverride, } /// How a config field can be written back to the runtime. diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 8d5f5eb71..5aec6561c 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay"; import type { RelayEvent, ManagedAgent } from "@/shared/api/types"; +import type { ControlResultFrame } from "@/shared/api/types"; import { getIdentity, putAgentSessionConfig } from "@/shared/api/tauri"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -38,6 +39,14 @@ const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +// Per-agent listeners for `control_result` frames. The ModelPicker subscribes +// here to learn the async outcome of a `switch_model` frame (the send is +// fire-and-forget; the harness replies out-of-band over the observer relay). +const controlResultListeners = new Map< + string, + Set<(frame: ControlResultFrame) => void> +>(); + // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). // @@ -194,6 +203,8 @@ async function handleRelayObserverEvent( appendAgentEvent(agentPubkey, parsed); if (parsed.kind === "session_config_captured") { void putAgentSessionConfig(agentPubkey, parsed.payload); + } else if (parsed.kind === "control_result") { + dispatchControlResult(agentPubkey, parsed.payload); } } catch (error) { if (activeGeneration !== generation) { @@ -271,6 +282,53 @@ export function subscribeAgentObserverStore(listener: () => void) { }; } +function isControlResultFrame(payload: unknown): payload is ControlResultFrame { + return ( + typeof payload === "object" && + payload !== null && + typeof (payload as { type?: unknown }).type === "string" && + typeof (payload as { status?: unknown }).status === "string" + ); +} + +function dispatchControlResult(agentPubkey: string, payload: unknown) { + if (!isControlResultFrame(payload)) { + return; + } + const subscribers = controlResultListeners.get(normalizePubkey(agentPubkey)); + if (!subscribers) { + return; + } + for (const subscriber of subscribers) { + subscriber(payload); + } +} + +/** + * Subscribe to `control_result` frames for a single agent. Returns an + * unsubscribe function. Used by the ModelPicker to learn the async outcome of + * a `switch_model` frame. + */ +export function subscribeControlResults( + agentPubkey: string, + listener: (frame: ControlResultFrame) => void, +) { + const key = normalizePubkey(agentPubkey); + const subscribers = controlResultListeners.get(key) ?? new Set(); + subscribers.add(listener); + controlResultListeners.set(key, subscribers); + return () => { + const current = controlResultListeners.get(key); + if (!current) { + return; + } + current.delete(listener); + if (current.size === 0) { + controlResultListeners.delete(key); + } + }; +} + export function getAgentObserverSnapshot( agentPubkey?: string | null, enabled?: boolean, diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 636247b77..3604241bf 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -29,6 +29,8 @@ function provenanceSentence( return "Set in Buzz"; case "personaDefault": return "Inherited from persona"; + case "runtimeOverride": + return "Live override (this session only)"; case "envVar": { if (writeVia.type === "respawnWithEnvVar") { return `From environment variable (${writeVia.envKey})`; @@ -84,7 +86,15 @@ function NormalizedRow({ <> {field.value ?? } {field.overriddenValue && ( - + {field.overriddenValue} )} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index ec3ab0362..66f8e2b01 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -1,4 +1,5 @@ import { ChevronDown } from "lucide-react"; +import { toast } from "sonner"; import { Spinner } from "@/shared/ui/spinner"; import React from "react"; @@ -13,6 +14,9 @@ import { getAgentModels, updateManagedAgent, } from "@/shared/api/tauri"; +import { switchManagedAgentModel } from "@/shared/api/agentControl"; +import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -39,6 +43,20 @@ export function ModelPicker({ const [needsRestart, setNeedsRestart] = React.useState(false); const [hasRequestedModels, setHasRequestedModels] = React.useState(false); + const isRunning = agent.status === "running" || agent.status === "deployed"; + const activeTurns = useActiveAgentTurns(agent.pubkey); + // A live switch rides the agent's running session(s) instead of persisting a + // new default. It applies only to a persona-linked running agent with at + // least one active turn — those are the channels the desktop can name in the + // `switch_model` frame (the ModelPicker has no other channel context). The + // harness then routes each named channel itself: a channel still mid-turn + // cancel-switch-requeues; one that finished between send and receipt takes + // the idle invalidate-and-reapply path. A persona-linked agent that is + // running but wholly idle has no nameable channel here, so it falls through + // to persisting the default (the only reachable lever from this surface). + const isLiveSwitch = + agent.personaId !== null && isRunning && activeTurns.length > 0; + const fetchModels = React.useCallback(async () => { setLoading(true); setError(null); @@ -97,18 +115,69 @@ export function ModelPicker({ envVar: "from env", configFile: "from config file", personaDefault: "persona default", + runtimeOverride: "live override", }; return labels[origin] ?? null; }, [configSurface]); + // Send a live `switch_model` frame to each channel the agent is working in + // and wait for the harness to acknowledge. The model catalog is the same + // across all of an agent's channels, so a single `unsupported_model` result + // rejects the whole pick; any other status confirms the frame landed. + const sendLiveSwitch = React.useCallback( + async (modelId: string) => { + const channelIds = activeTurns.map((turn) => turn.channelId); + + const settled = new Promise<"ok" | "unsupported">((resolve) => { + let unsubscribe = () => {}; + const finish = (outcome: "ok" | "unsupported") => { + window.clearTimeout(timeout); + unsubscribe(); + resolve(outcome); + }; + // No reply in time: treat as sent. The override still rides the + // requeued/next session; we just can't confirm synchronously. + const timeout = window.setTimeout(() => finish("ok"), 8_000); + unsubscribe = subscribeControlResults(agent.pubkey, (frame) => { + if (frame.type !== "switch_model" || frame.modelId !== modelId) { + return; + } + finish(frame.status === "unsupported_model" ? "unsupported" : "ok"); + }); + }); + + await Promise.all( + channelIds.map((channelId) => + switchManagedAgentModel(agent.pubkey, channelId, modelId), + ), + ); + + return settled; + }, + [activeTurns, agent.pubkey], + ); + const handleModelChange = async (modelId: string) => { setSaving(true); + setError(null); try { + if (isLiveSwitch) { + const outcome = await sendLiveSwitch(modelId); + if (outcome === "unsupported") { + toast.error("That model isn't available for this agent."); + return; + } + toast.success("Model switched for this session."); + onModelChanged?.(); + return; + } + + // Non-live path (idle, stopped, or non-persona): persist the default. await updateManagedAgent({ pubkey: agent.pubkey, model: modelId === modelsData?.agentDefaultModel ? null : modelId, }); - if (agent.status === "running" || agent.status === "deployed") { + if (isRunning) { setNeedsRestart(true); } onModelChanged?.(); diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 0f4f89d09..f7b99ac98 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -243,24 +243,25 @@ export function ProfileSummaryView({ ) : null} {metadataFields.length > 0 ? ( - - ) : null} - - {isBot && isOwner === true && managedAgent !== undefined ? ( -
-
- -

- Configuration -

-
-
- -
-
+ +
+ +

+ Configuration +

+
+ + + ) : null + } + /> ) : null} ); @@ -789,7 +790,13 @@ function buildOwnerFields({ return fields; } -function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { +function ProfileFieldGroup({ + fields, + footer, +}: { + fields: ProfileField[]; + footer?: React.ReactNode; +}) { const publicKeyLabel = "Public key"; const ownedByLabel = "Owned by"; const statusLabel = "Status"; @@ -821,6 +828,7 @@ function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { {orderedFields.map((field) => ( ))} + {footer} ); diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 923d93922..677f0ffad 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -11,3 +11,21 @@ export async function cancelManagedAgentTurn( }); return { status: "sent" }; } + +/** + * Send a live model-switch control frame to a running agent. The switch rides + * the harness's cancel-switch-requeue path (busy turn) or invalidate-and-reapply + * (idle); the outcome arrives asynchronously as a `control_result` observer + * frame, not as the return value here. This is fire-and-forget on the send side. + */ +export async function switchManagedAgentModel( + pubkey: string, + channelId: string, + modelId: string, +): Promise { + await sendAgentObserverControl(pubkey, { + type: "switch_model", + channelId, + modelId, + }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 4b058f87e..ab5741556 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -410,6 +410,25 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; +/** + * Outcome of a live `switch_model` control frame, surfaced asynchronously via + * the agent's `control_result` observer frame. Busy path: `sent` (cancel + + * requeue on the new model) or `turn_ending` (oneshot already consumed this + * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. + */ +export type SwitchManagedAgentModelStatus = + | "sent" + | "turn_ending" + | "switched" + | "unsupported_model" + | "no_active_turn"; + +export type ControlResultFrame = { + type: "cancel_turn" | "switch_model"; + status: string; + modelId?: string; +}; + export type AcpAvailabilityStatus = | "available" | "adapter_missing" @@ -485,7 +504,8 @@ export type ConfigOrigin = | "acpConfigOption" | "envVar" | "configFile" - | "personaDefault"; + | "personaDefault" + | "runtimeOverride"; export type ConfigWriteMechanism = | { type: "respawnWithEnvVar"; envKey: string } From 0c9cc4f645874e1afe036cf84c9bc5d6aaf4b451 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 14:44:00 -0400 Subject: [PATCH 04/14] fix(config-bridge): gate runtime override on harness model_overridden flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-override display keyed off `acp_model != persona_model`, which cannot distinguish a live ModelPicker pick from a session left stale after a mid-life persona edit. Editing a running persona-linked agent's model A->B false-positived as a live override — re-introducing the display-versus-reality bug this surface exists to kill. The harness now stamps a `model_overridden` flag into session_config_captured (true only when a SwitchModel control signal set desired_model, reset on spawn); the reader gates the override on that flag. Also fix multi-channel sendLiveSwitch: it resolved on the first control_result of any status, so a fast `sent` from one channel masked a later `unsupported_model` from another. Now any single rejection fails the pick immediately (fail-fast); success requires every channel to acknowledge. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/lib.rs | 3 + crates/buzz-acp/src/pool.rs | 8 ++ .../src-tauri/src/commands/agent_config.rs | 5 ++ .../managed_agents/config_bridge/reader.rs | 88 ++++++++++++++----- .../src/managed_agents/config_bridge/types.rs | 6 ++ .../src/features/agents/ui/ModelPicker.tsx | 19 +++- 6 files changed, 104 insertions(+), 25 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index aab86972b..98eaef0c6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1137,6 +1137,7 @@ async fn tokio_main() -> Result<()> { state: SessionState::default(), model_capabilities: None, desired_model: config.model.clone(), + model_overridden: false, protocol_version, })); } @@ -1573,6 +1574,7 @@ async fn tokio_main() -> Result<()> { state: SessionState::default(), model_capabilities: None, desired_model: config.model.clone(), + model_overridden: false, protocol_version, }; pool.return_agent(agent); @@ -3578,6 +3580,7 @@ mod error_outcome_emission_tests { state: Default::default(), model_capabilities: None, desired_model: None, + model_overridden: false, // Error branches under test never read this; 1 is the legacy // non-systemPrompt path, the simplest valid value. protocol_version: 1, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ae8560beb..53f729ea8 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -135,6 +135,11 @@ pub struct OwnedAgent { pub model_capabilities: Option, /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. pub desired_model: Option, + /// Whether `desired_model` was set by a live `SwitchModel` control signal + /// (as opposed to being derived from config/persona at spawn). Used by the + /// desktop reader to distinguish a genuine runtime override from a stale + /// session whose persona model was edited. Reset on spawn/restart. + pub model_overridden: bool, /// Protocol version reported by the agent in its initialize response. /// Agents declaring >= 2 support `systemPrompt` in session/new. pub protocol_version: u32, @@ -470,6 +475,7 @@ impl AgentPool { } agent.desired_model = Some(model_id.to_string()); + agent.model_overridden = true; agent.state.invalidate_channel(&channel_id); IdleSwitchResult::Switched } @@ -551,6 +557,7 @@ async fn create_session_and_apply_model( "configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null), "modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null), "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null), + "modelOverridden": agent.model_overridden, }), ); @@ -1332,6 +1339,7 @@ pub async fn run_prompt_task( // applies the new model. Runtime-only — never persisted. if let ControlSignal::SwitchModel(ref model_id) = control_signal { agent.desired_model = Some(model_id.clone()); + agent.model_overridden = true; } // Control signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index c5694ffa4..69379b3e8 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -242,12 +242,17 @@ pub fn put_agent_session_config( let config_options = parse_config_options(payload.get("configOptions")); let available_modes = parse_modes(&config_options, payload.get("modes")); let (available_models, current_model) = parse_models(payload.get("models")); + let model_overridden = payload + .get("modelOverridden") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let cache = SessionConfigCache { config_options, available_modes, available_models, current_model, + model_overridden, goose_native_config: None, captured_at: crate::util::now_iso(), }; 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 8159a6a43..aad51a19f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -49,6 +49,8 @@ pub(crate) fn read_config_surface( .and_then(|k| record.env_vars.get(k)) .cloned(); + let model_overridden = session_cache.is_some_and(|c| c.model_overridden); + let normalized = NormalizedConfig { model: Some(apply_runtime_override( build_model_field( @@ -62,6 +64,7 @@ pub(crate) fn read_config_surface( ), acp_model.as_deref(), persona_model, + model_overridden, )), provider: build_provider_field( &record_provider, @@ -254,23 +257,29 @@ fn model_write_mechanism( } } -/// Re-key the model field as a live runtime override when the ACP session's -/// current model diverges from the persona model (Phase 3c). +/// Re-key the model field as a live runtime override when the harness signals +/// that a `SwitchModel` control signal set the model (Phase 3c). /// -/// The override-active signal is `acp_model != persona_model` — NOT the -/// harness-only `desired_model`, which the desktop reader cannot see. A -/// persona-linked agent spawns with its ACP current model equal to the persona -/// model, so divergence only happens after a live ModelPicker switch. +/// The override-active signal is `model_overridden` from the +/// `session_config_captured` payload — NOT `acp_model != persona_model`, which +/// would false-positive when a persona model is edited mid-life while the +/// session is stale on the old model. /// /// `persona_model` is `Some` only for persona-linked agents with no explicit /// record model; an explicit pick has no persona baseline to override, so the -/// field passes through unchanged. The override preserves the base field's -/// write mechanism — only the displayed value, origin, and secondary change. +/// field passes through unchanged. The `acp == persona` short-circuit keeps a +/// pick of the persona model itself from rendering a no-op "override of X with +/// X". The override preserves the base field's write mechanism — only the +/// displayed value, origin, and secondary change. fn apply_runtime_override( base: NormalizedField, acp_model: Option<&str>, persona_model: Option<&str>, + model_overridden: bool, ) -> NormalizedField { + if !model_overridden { + return base; + } let (Some(acp), Some(persona)) = (acp_model, persona_model) else { return base; }; @@ -596,6 +605,7 @@ mod tests { available_modes: vec![], available_models: vec![], current_model: Some("claude-opus-4".to_string()), + model_overridden: false, goose_native_config: None, captured_at: "".to_string(), }; @@ -619,6 +629,7 @@ mod tests { available_modes: vec![], available_models: vec![], current_model: Some("acp-model".to_string()), + model_overridden: false, goose_native_config: None, captured_at: "".to_string(), }; @@ -671,15 +682,15 @@ mod tests { // ── Runtime override (Phase 3c) ────────────────────────────────────── // - // A live ModelPicker switch surfaces as the ACP session's `current_model` - // diverging from the persona model. The reader keys the override-active - // decision off that divergence (acp_model != persona_model), NOT off the - // harness-only `desired_model` (which the desktop reader cannot see). + // A live ModelPicker switch is signalled by `model_overridden: true` in the + // `session_config_captured` payload. The reader keys the override-active + // decision off that flag — NOT off `acp_model != persona_model`, which would + // false-positive when a persona model is edited mid-life. #[test] - fn runtime_override_wins_display_when_acp_model_diverges_from_persona() { + fn runtime_override_wins_display_when_model_overridden_is_true() { // Persona-linked agent (record.model == None); persona == "persona-model". - // A live switch pushed "live-model" to the session. + // A live switch pushed "live-model" to the session and set model_overridden. let record = test_record(); let runtime = test_runtime(); let cache = SessionConfigCache { @@ -687,6 +698,7 @@ mod tests { available_modes: vec![], available_models: vec![], current_model: Some("live-model".to_string()), + model_overridden: true, goose_native_config: None, captured_at: "".to_string(), }; @@ -705,11 +717,10 @@ mod tests { } #[test] - fn no_runtime_override_when_acp_model_equals_persona() { + fn no_runtime_override_when_model_overridden_is_false() { // At spawn the session's current_model == persona model (BUZZ_ACP_MODEL - // is set to the persona model). No divergence => no override; the field - // falls through to normal precedence (persona-wins surface, no override - // origin and no overridden secondary value). + // is set to the persona model) and model_overridden is false. No override; + // the field falls through to normal precedence. let record = test_record(); let runtime = test_runtime(); let cache = SessionConfigCache { @@ -717,6 +728,7 @@ mod tests { available_modes: vec![], available_models: vec![], current_model: Some("persona-model".to_string()), + model_overridden: false, goose_native_config: None, captured_at: "".to_string(), }; @@ -725,14 +737,48 @@ mod tests { read_config_surface(&record, Some(runtime), Some(&cache), Some("persona-model")); let model = surface.normalized.model.unwrap(); - // No divergence => the override branch is not taken: origin is the - // normal precedence result, never RuntimeOverride, and the persona is - // not surfaced as a secondary override value. + // model_overridden is false => the override branch is not taken: origin + // is the normal precedence result, never RuntimeOverride. assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); assert_eq!(model.value.as_deref(), Some("persona-model")); assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); } + #[test] + fn no_false_positive_override_when_persona_edited_mid_life() { + // Persona-linked agent whose persona model was edited A→B while the + // session is stale on the old model A. `model_overridden` is false + // because no SwitchModel control signal was sent — the session is merely + // stale. Despite acp_model("A") != persona_model("B"), no RuntimeOverride + // should be displayed. + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("old-persona-model".to_string()), + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface( + &record, + Some(runtime), + Some(&cache), + Some("new-persona-model"), + ); + let model = surface.normalized.model.unwrap(); + + // model_overridden is false => no RuntimeOverride, even though + // acp_model != persona_model. The old divergence-based signal would + // have false-positived here. The persona is never surfaced as the + // overridden secondary (that marker is exclusive to a real override). + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } + #[test] fn persona_provider_injection_produces_persona_default_origin() { let mut record = test_record(); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 3afe5760f..ae6db7dcb 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -191,6 +191,12 @@ pub struct SessionConfigCache { pub available_modes: Vec, pub available_models: Vec, pub current_model: Option, + /// Whether the harness's `desired_model` was set by a live `SwitchModel` + /// control signal (true) vs derived from config/persona at spawn (false). + /// Used by the reader to distinguish a genuine runtime override from a + /// stale session whose persona model was edited mid-life. + #[serde(default)] + pub model_overridden: bool, pub goose_native_config: Option, pub captured_at: String, } diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 66f8e2b01..0b788b509 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -121,15 +121,17 @@ export function ModelPicker({ }, [configSurface]); // Send a live `switch_model` frame to each channel the agent is working in - // and wait for the harness to acknowledge. The model catalog is the same - // across all of an agent's channels, so a single `unsupported_model` result - // rejects the whole pick; any other status confirms the frame landed. + // and wait for the harness to acknowledge. Any single `unsupported_model` + // result rejects the whole pick immediately; all other statuses must arrive + // from every channel before resolving success. const sendLiveSwitch = React.useCallback( async (modelId: string) => { const channelIds = activeTurns.map((turn) => turn.channelId); + const outstanding = channelIds.length; const settled = new Promise<"ok" | "unsupported">((resolve) => { let unsubscribe = () => {}; + let remaining = outstanding; const finish = (outcome: "ok" | "unsupported") => { window.clearTimeout(timeout); unsubscribe(); @@ -142,7 +144,16 @@ export function ModelPicker({ if (frame.type !== "switch_model" || frame.modelId !== modelId) { return; } - finish(frame.status === "unsupported_model" ? "unsupported" : "ok"); + if (frame.status === "unsupported_model") { + // Any single failure rejects the whole pick immediately. + finish("unsupported"); + return; + } + // sent / switched / turn_ending — count as success for this channel. + remaining -= 1; + if (remaining <= 0) { + finish("ok"); + } }); }); From a28548bc6cd2c8952de915f78cacc6cb1beffe2a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 15:05:10 -0400 Subject: [PATCH 05/14] fix(desktop): use rem text tokens in config-bridge UI The config-bridge provenance rows and ModelPicker origin/restart labels used hardcoded text-[11px]/text-[10px] literals. The px-text guard (added to main via the rem-token migration) forbids arbitrary font-size literals so text scales with Cmd +/- zoom. Swap all four to the text-2xs meta-text token (0.6875rem), the documented sibling for these decorative sub-labels. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/ui/AgentConfigPanel.tsx | 4 ++-- desktop/src/features/agents/ui/ModelPicker.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 3604241bf..9c30dd834 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -102,7 +102,7 @@ function NormalizedRow({ )} {field.value && ( -
+
{provenanceSentence(field.origin, field.writeVia, configFilePath)}
)} @@ -128,7 +128,7 @@ function AdvancedRow({ )}
{field.value && ( -
+
{provenanceSentence(field.origin, field.writeVia, configFilePath)}
)} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 0b788b509..cbcef0cc7 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -212,7 +212,7 @@ export function ModelPicker({ > {displayLabel} {modelOriginLabel ? ( - + ({modelOriginLabel}) ) : null} From 6fd97bd29bbc64ebe4fc4ee1bc4e137230a7684c Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 16:27:05 -0400 Subject: [PATCH 06/14] test(desktop): unit-pin multi-channel live-switch fail-fast logic The any-unsupported-fails-fast counting for a live model switch was locked inside the sendLiveSwitch useCallback in ModelPicker, verified by read but not unit-pinned. Extract the counting (remaining decrement, immediate unsupported reject, resolve-once, unsubscribe-once, timeout fallback) into a pure awaitLiveSwitchOutcome helper with the relay subscription, per-channel sends, and timeout scheduler injected. The component wires the real subscribeControlResults / window timer / dispatch; behavior is unchanged. The helper is node:test-drivable with synthetic frames and a manual clock. The masking-guard test fails against a first-ack-resolves variant and passes against the shipped fail-fast logic. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/lib/liveSwitchOutcome.test.mjs | 143 ++++++++++++++++++ .../features/agents/lib/liveSwitchOutcome.ts | 66 ++++++++ .../src/features/agents/ui/ModelPicker.tsx | 53 +++---- 3 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs create mode 100644 desktop/src/features/agents/lib/liveSwitchOutcome.ts diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs new file mode 100644 index 000000000..06077eaf4 --- /dev/null +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts"; + +const MODEL = "goose-claude-fable-5"; + +function frame(status, overrides = {}) { + return { type: "switch_model", status, modelId: MODEL, ...overrides }; +} + +/** + * A controllable test harness mirroring the real wiring: a single-listener + * pub/sub whose unsubscribe genuinely detaches (so post-unsubscribe pushes are + * no-ops, matching `observerRelayStore`), a manual timeout, and a deferred + * `sendSwitches` the test resolves explicitly. + */ +function harness(channelCount) { + let listener = null; + let timeoutCb = null; + let unsubscribeCalls = 0; + let cancelTimeoutCalls = 0; + let sendResolve; + const sendStarted = new Promise((resolve) => { + sendResolve = resolve; + }); + + const outcome = awaitLiveSwitchOutcome({ + channelCount, + modelId: MODEL, + subscribe: (fn) => { + listener = fn; + return () => { + unsubscribeCalls += 1; + listener = null; + }; + }, + sendSwitches: () => { + sendResolve(); + return Promise.resolve(); + }, + scheduleTimeout: (cb) => { + timeoutCb = cb; + return () => { + cancelTimeoutCalls += 1; + }; + }, + }); + + return { + outcome, + sendStarted, + push: (f) => listener?.(f), + fireTimeout: () => timeoutCb?.(), + get unsubscribeCalls() { + return unsubscribeCalls; + }, + get cancelTimeoutCalls() { + return cancelTimeoutCalls; + }, + }; +} + +test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => { + const h = harness(2); + // Channel A acks fast as `sent`; a first-ack-resolves impl would settle "ok" + // here. The fail-fast contract must keep waiting and then reject on B. + h.push(frame("sent")); + h.push(frame("unsupported_model")); + assert.equal(await h.outcome, "unsupported"); +}); + +test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", async () => { + const h = harness(3); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("sent")); + await Promise.resolve(); + assert.equal(settled, false, "must not resolve on the first ack"); + + h.push(frame("switched")); + await Promise.resolve(); + assert.equal(settled, false, "must not resolve before the last ack"); + + h.push(frame("turn_ending")); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes exactly once", async () => { + const h = harness(2); + h.push(frame("unsupported_model")); + assert.equal(await h.outcome, "unsupported"); + assert.equal(h.unsubscribeCalls, 1); + assert.equal(h.cancelTimeoutCalls, 1); + + // A second rejection arriving after the first must not re-resolve or + // re-unsubscribe — the listener is already detached. + h.push(frame("unsupported_model")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + +test("awaitLiveSwitchOutcome ignores frames for a different model or control type", async () => { + const h = harness(1); + h.push(frame("sent", { modelId: "some-other-model" })); + h.push({ type: "cancel_turn", status: "sent", modelId: MODEL }); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + await Promise.resolve(); + assert.equal(settled, false, "unrelated frames must not advance the count"); + + h.push(frame("switched")); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome resolves ok via the timeout fallback when the harness never replies", async () => { + const h = harness(2); + h.fireTimeout(); + assert.equal(await h.outcome, "ok"); + assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes"); +}); + +test("awaitLiveSwitchOutcome fires the per-channel sends after subscribing", async () => { + const h = harness(1); + // The subscription is registered before the sends fire, so a frame arriving + // mid-send is never dropped. Awaiting sendStarted proves sends ran. + await h.sendStarted; + h.push(frame("sent")); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome with zero channels resolves ok at the timeout (no acks expected)", async () => { + // No active turns means channelCount 0: remaining starts at 0 but the success + // resolve only fires inside a frame callback, so with no frames the timeout + // fallback is what settles it. This documents the degenerate path. + const h = harness(0); + h.fireTimeout(); + assert.equal(await h.outcome, "ok"); +}); diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts new file mode 100644 index 000000000..d12261e59 --- /dev/null +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -0,0 +1,66 @@ +import type { ControlResultFrame } from "@/shared/api/types"; + +/** + * Resolve the outcome of a live `switch_model` across one or more channels. + * + * A live switch fires a `switch_model` frame per active channel and learns each + * channel's result asynchronously over the observer relay. The fail-fast rule: + * any single `unsupported_model` result rejects the whole pick immediately; + * every other status must arrive from every channel before resolving success. + * If the harness never replies, the fallback timeout resolves `"ok"` — the + * override still rides the requeued/next session, we just can't confirm it + * synchronously. + * + * The counting lives here, isolated from React and the relay so it can be unit + * tested with synthetic frames and a fake clock. The caller injects the + * relay subscription, the per-channel sends, and the timeout scheduler. + */ +export async function awaitLiveSwitchOutcome({ + channelCount, + modelId, + subscribe, + sendSwitches, + scheduleTimeout, +}: { + /** Number of channels the switch was fired to — the success threshold. */ + channelCount: number; + /** Model being switched to; frames for any other model are ignored. */ + modelId: string; + /** Register a control-result listener; returns an unsubscribe function. */ + subscribe: (listener: (frame: ControlResultFrame) => void) => () => void; + /** Fire the per-channel `switch_model` sends. Resolves when all are sent. */ + sendSwitches: () => Promise; + /** Schedule the no-reply fallback; returns a cancel function. */ + scheduleTimeout: (onTimeout: () => void) => () => void; +}): Promise<"ok" | "unsupported"> { + const settled = new Promise<"ok" | "unsupported">((resolve) => { + let unsubscribe = () => {}; + let cancelTimeout = () => {}; + let remaining = channelCount; + const finish = (outcome: "ok" | "unsupported") => { + cancelTimeout(); + unsubscribe(); + resolve(outcome); + }; + cancelTimeout = scheduleTimeout(() => finish("ok")); + unsubscribe = subscribe((frame) => { + if (frame.type !== "switch_model" || frame.modelId !== modelId) { + return; + } + if (frame.status === "unsupported_model") { + // Any single failure rejects the whole pick immediately. + finish("unsupported"); + return; + } + // sent / switched / turn_ending — count as success for this channel. + remaining -= 1; + if (remaining <= 0) { + finish("ok"); + } + }); + }); + + await sendSwitches(); + + return settled; +} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index cbcef0cc7..ae9d767f2 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -15,6 +15,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import { switchManagedAgentModel } from "@/shared/api/agentControl"; +import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome"; import { subscribeControlResults } from "@/features/agents/observerRelayStore"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { Button } from "@/shared/ui/button"; @@ -125,45 +126,27 @@ export function ModelPicker({ // result rejects the whole pick immediately; all other statuses must arrive // from every channel before resolving success. const sendLiveSwitch = React.useCallback( - async (modelId: string) => { + (modelId: string) => { const channelIds = activeTurns.map((turn) => turn.channelId); - const outstanding = channelIds.length; - - const settled = new Promise<"ok" | "unsupported">((resolve) => { - let unsubscribe = () => {}; - let remaining = outstanding; - const finish = (outcome: "ok" | "unsupported") => { - window.clearTimeout(timeout); - unsubscribe(); - resolve(outcome); - }; + return awaitLiveSwitchOutcome({ + channelCount: channelIds.length, + modelId, + subscribe: (listener) => + subscribeControlResults(agent.pubkey, listener), + sendSwitches: async () => { + await Promise.all( + channelIds.map((channelId) => + switchManagedAgentModel(agent.pubkey, channelId, modelId), + ), + ); + }, // No reply in time: treat as sent. The override still rides the // requeued/next session; we just can't confirm synchronously. - const timeout = window.setTimeout(() => finish("ok"), 8_000); - unsubscribe = subscribeControlResults(agent.pubkey, (frame) => { - if (frame.type !== "switch_model" || frame.modelId !== modelId) { - return; - } - if (frame.status === "unsupported_model") { - // Any single failure rejects the whole pick immediately. - finish("unsupported"); - return; - } - // sent / switched / turn_ending — count as success for this channel. - remaining -= 1; - if (remaining <= 0) { - finish("ok"); - } - }); + scheduleTimeout: (onTimeout) => { + const timeout = window.setTimeout(onTimeout, 8_000); + return () => window.clearTimeout(timeout); + }, }); - - await Promise.all( - channelIds.map((channelId) => - switchManagedAgentModel(agent.pubkey, channelId, modelId), - ), - ); - - return settled; }, [activeTurns, agent.pubkey], ); From b79e778407b8767fc194a015ad65fba8c80c3377 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 16:36:54 -0400 Subject: [PATCH 07/14] test: harden liveSwitchOutcome scenario 2 interim resolve guard The interim settled===false checks used a single await Promise.resolve() drain, which the outcome.then callback outruns by one microtask tick, so the guard passed even against a first-ack-resolves variant. Drain five ticks before each interim check so it deterministically regresses an early resolve. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/lib/liveSwitchOutcome.test.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs index 06077eaf4..737d84b86 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -77,12 +77,23 @@ test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", asyn settled = true; }); + // The `.then` that flips `settled` flushes on a later microtask tick than a + // single drain, so a single `await Promise.resolve()` would let this + // assertion pass even against a first-ack-resolves bug. Draining several + // ticks guarantees a resolved promise's callback has run, so the interim + // `settled === false` checks deterministically regress an early resolve. + const drainMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + }; + h.push(frame("sent")); - await Promise.resolve(); + await drainMicrotasks(); assert.equal(settled, false, "must not resolve on the first ack"); h.push(frame("switched")); - await Promise.resolve(); + await drainMicrotasks(); assert.equal(settled, false, "must not resolve before the last ack"); h.push(frame("turn_ending")); From 54a94b1fb095f35ace3fdc3cb05c4493292f6ded Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 17:11:27 -0400 Subject: [PATCH 08/14] fix(desktop): surface genuine-explicit live model switch in config panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A genuine-explicit agent (own record model, no persona) that live-switched mid-session rendered nowhere: resolve_config_surface passed no override baseline for an explicit record, so apply_runtime_override early-returned and the panel showed the stale record model as primary with the live model struck through — display contradicting reality. Carry the override baseline as a typed (value, origin) pair end-to-end so the secondary is tagged by its true source (BuzzExplicit for the record case, PersonaDefault for personas) instead of a hardcoded PersonaDefault. Build the record-model baseline only when model_overridden is true, so a persona edited mid-life still does not false-positive. On a live pick equal to the baseline, yield a clean single-value field rather than passing the pre-polluted base through (build_model_field independently sets an AcpConfigOption secondary for the record-plus-live-session case). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/agent_config.rs | 134 ++++++++++++++++-- .../managed_agents/config_bridge/reader.rs | 63 +++++--- 2 files changed, 170 insertions(+), 27 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 69379b3e8..8bd642e74 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -52,14 +52,29 @@ fn resolve_config_surface( record.model.clone(), ); - // Retain the persona model so the reader can detect a live runtime override - // (ACP current model diverging from the persona model). Only meaningful when - // the record had no explicit model — an explicit pick is the user's choice, - // not a persona baseline to override. - let persona_model_for_override = if had_model { - None + // Build the baseline the reader overrides a live model against, paired with + // its true origin so the secondary is tagged correctly. Two sources: + // - persona-linked, no explicit record model: the persona model is the + // baseline (PersonaDefault). + // - genuine-explicit (record had its own model) that live-switched: the + // record's own model is the baseline (BuzzExplicit). Gated behind + // `model_overridden` so a persona edited mid-life (override flag false) + // never synthesizes a baseline and false-positives an override. + // An explicit pick with no live switch has no baseline to override. + let model_overridden = session_cache.is_some_and(|c| c.model_overridden); + let baseline = if had_model { + if model_overridden { + record + .model + .clone() + .map(|m| (m, ConfigOrigin::BuzzExplicit)) + } else { + None + } } else { - persona_model.clone() + persona_model + .clone() + .map(|m| (m, ConfigOrigin::PersonaDefault)) }; // Inject resolved persona values into the record where absent. @@ -83,7 +98,7 @@ fn resolve_config_surface( &record, runtime_meta, session_cache, - persona_model_for_override.as_deref(), + baseline.as_ref().map(|(m, o)| (m.as_str(), o.clone())), ); // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. @@ -496,6 +511,21 @@ mod tests { } } + /// A post-spawn session cache whose live model is `current_model` and whose + /// `model_overridden` flag records whether a `SwitchModel` control signal set + /// it (the live-switch signal). + fn session_cache(current_model: &str, model_overridden: bool) -> SessionConfigCache { + SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some(current_model.to_string()), + model_overridden, + goose_native_config: None, + captured_at: "".to_string(), + } + } + /// The write path must see a persona-inherited model. Without persona /// resolution `surface.normalized.model` would be `None` and /// `plan_config_write` would return "field not available for this runtime". @@ -532,4 +562,92 @@ mod tests { assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); } + + /// Part A — pending-pick: a genuine-explicit pick X with a divergent live + /// model Y but `model_overridden == false` (the live switch is not yet + /// applied — a restart is pending) must keep X as the primary and must NOT + /// surface Y as an override row. The live `acp_model` does not win. This + /// FAILS against a let-live-acp-win variant (one that dropped the + /// `model_overridden` gate), so it is not vacuous. + #[test] + fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", false); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_ne!(model.overridden_value.as_deref(), Some("model-y")); + } + + /// W2 — genuine-explicit live switch: record.model = X, no persona, + /// `model_overridden == true`, live model = Y. The live Y must render as the + /// primary with a `RuntimeOverride` origin and X as the secondary tagged + /// `BuzzExplicit` (its true source — NOT `PersonaDefault`). FAILS against the + /// shipped no-persona early-return, which left X as primary and Y struck. + #[test] + fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("model-x")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); + } + + /// Y==X collision: a genuine-explicit agent live-switches to the SAME value + /// it already had. There is no real divergence, so the field must be a clean + /// single value with NO secondary row. FAILS against a naive `return base` + /// that would leak the `AcpConfigOption` row `build_model_field` populates. + #[test] + fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-x", true); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + assert_eq!(model.overridden_value, None); + assert_eq!(model.overridden_origin, None); + } + + /// Persona parity (regression): a persona-linked agent with no explicit + /// record model that live-switches still renders the persona model as the + /// secondary tagged `PersonaDefault` — the typed-baseline change must NOT + /// regress the persona arm to a different origin. + #[test] + fn persona_linked_live_switch_keeps_persona_default_secondary() { + let record = agent_record(); + let personas = vec![persona_with_model("persona-model")]; + let cache = session_cache("model-y", true); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } } 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 aad51a19f..2e1173181 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -11,7 +11,7 @@ pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, - persona_model: Option<&str>, + baseline: Option<(&str, ConfigOrigin)>, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -63,7 +63,7 @@ pub(crate) fn read_config_surface( session_cache, ), acp_model.as_deref(), - persona_model, + baseline, model_overridden, )), provider: build_provider_field( @@ -265,32 +265,49 @@ fn model_write_mechanism( /// would false-positive when a persona model is edited mid-life while the /// session is stale on the old model. /// -/// `persona_model` is `Some` only for persona-linked agents with no explicit -/// record model; an explicit pick has no persona baseline to override, so the -/// field passes through unchanged. The `acp == persona` short-circuit keeps a -/// pick of the persona model itself from rendering a no-op "override of X with -/// X". The override preserves the base field's write mechanism — only the -/// displayed value, origin, and secondary change. +/// `baseline` is the value the live model overrides, paired with its true +/// origin — `(persona_model, PersonaDefault)` for a persona-linked agent, or +/// `(record_model, BuzzExplicit)` for a genuine-explicit agent that live- +/// switched. It is `Some` only when there is such a baseline to override +/// against; otherwise the field passes through unchanged. Carrying the origin +/// in the pair (rather than hardcoding it) lets the secondary be tagged by its +/// real source instead of always reading `PersonaDefault`. +/// +/// The `acp == baseline_value` short-circuit keeps a live pick of the baseline +/// model itself from rendering a no-op "override of X with X". It yields a +/// CLEAN single-value field — `overridden_value`/`overridden_origin` cleared — +/// rather than passing `base` through, because `build_model_field` already +/// populates `base`'s secondary with an `AcpConfigOption` row for the +/// record-model-plus-live-session case; returning `base` would leak that +/// spurious row. The override preserves the base field's write mechanism — only +/// the displayed value, origin, and secondary change. fn apply_runtime_override( base: NormalizedField, acp_model: Option<&str>, - persona_model: Option<&str>, + baseline: Option<(&str, ConfigOrigin)>, model_overridden: bool, ) -> NormalizedField { if !model_overridden { return base; } - let (Some(acp), Some(persona)) = (acp_model, persona_model) else { + let (Some(acp), Some((baseline_value, baseline_origin))) = (acp_model, baseline) else { return base; }; - if acp == persona { - return base; + if acp == baseline_value { + // Live pick equals the baseline — no real divergence. Strip any + // secondary `build_model_field` may have produced so the panel shows a + // single clean value rather than "X overridden by X". + return NormalizedField { + overridden_value: None, + overridden_origin: None, + ..base + }; } NormalizedField { value: Some(acp.to_string()), origin: ConfigOrigin::RuntimeOverride, - overridden_value: Some(persona.to_string()), - overridden_origin: Some(ConfigOrigin::PersonaDefault), + overridden_value: Some(baseline_value.to_string()), + overridden_origin: Some(baseline_origin), ..base } } @@ -703,8 +720,12 @@ mod tests { captured_at: "".to_string(), }; - let surface = - read_config_surface(&record, Some(runtime), Some(&cache), Some("persona-model")); + let surface = read_config_surface( + &record, + Some(runtime), + Some(&cache), + Some(("persona-model", ConfigOrigin::PersonaDefault)), + ); let model = surface.normalized.model.unwrap(); // Override wins the display value with a runtime-override origin. @@ -733,8 +754,12 @@ mod tests { captured_at: "".to_string(), }; - let surface = - read_config_surface(&record, Some(runtime), Some(&cache), Some("persona-model")); + let surface = read_config_surface( + &record, + Some(runtime), + Some(&cache), + Some(("persona-model", ConfigOrigin::PersonaDefault)), + ); let model = surface.normalized.model.unwrap(); // model_overridden is false => the override branch is not taken: origin @@ -767,7 +792,7 @@ mod tests { &record, Some(runtime), Some(&cache), - Some("new-persona-model"), + Some(("new-persona-model", ConfigOrigin::PersonaDefault)), ); let model = surface.normalized.model.unwrap(); From e8d3fa2ee1d6c8faa3886061f3f2be4e202cdaef Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 18:20:44 -0400 Subject: [PATCH 09/14] fix(desktop): stop build_model_field leaking acp override into secondary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-acp-vs-record override is now exclusively apply_runtime_override's job, gated on model_overridden. build_model_field's acp-derived secondary predated that gate: with record_model=Some(X) and acp_model=Some(Y) it populated overridden_value=Some(Y) unconditionally, and that row passed straight through apply_runtime_override's !model_overridden early-return — surfacing a live override before any switch was applied. Collapse the secondary to express only the static record-vs-file precedence (a Buzz-explicit model shadowing a config-file model); drop all acp_model references from it. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../managed_agents/config_bridge/reader.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 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 2e1173181..18576d4f3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -201,21 +201,15 @@ fn build_model_field( (None, ConfigOrigin::EnvVar) }; - let overridden_value = if record_model.is_some() { - file_model.clone().or(acp_model.clone()) - } else if acp_model.is_some() && file_model.is_some() { - file_model.clone() + // The secondary expresses ONLY the static record-vs-file precedence: a + // Buzz-explicit model shadowing a config-file model. The live-session + // override (acp vs record/persona) is exclusively `apply_runtime_override`'s + // job, gated on `model_overridden`. Surfacing `acp_model` here would leak an + // override row even when no live switch has been applied. + let (overridden_value, overridden_origin) = if record_model.is_some() && file_model.is_some() { + (file_model.clone(), Some(ConfigOrigin::ConfigFile)) } else { - None - }; - let overridden_origin = if record_model.is_some() && file_model.is_some() { - Some(ConfigOrigin::ConfigFile) - } else if record_model.is_some() && acp_model.is_some() { - Some(ConfigOrigin::AcpConfigOption) - } else if acp_model.is_some() && file_model.is_some() { - Some(ConfigOrigin::ConfigFile) - } else { - None + (None, None) }; let write_via = model_write_mechanism( From a7392da6f6449573fa6c264d9781729d8663443a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 21:17:45 -0400 Subject: [PATCH 10/14] test(desktop): re-ground config-bridge screenshot spec to sentence UI The v4 screenshots captured the old origin-badge UI. The shipped surface now renders inline provenance sentences ("Set in Buzz", "Inherited from persona", "Live override (this session only)", etc.), a folded config panel, and a non-struck persona baseline for runtime overrides. Re-grounds the screenshot scenarios to the sentence-based render and adds a multiOriginSurface fixture (one distinct origin per row) so the provenance- sentences shot witnesses multiple distinct sentences in one frame instead of duplicating the folded-panel capture. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/testing/e2eBridge.ts | 124 +++++++++++++ .../e2e/config-bridge-screenshots.spec.ts | 168 ++++++++++-------- 2 files changed, 220 insertions(+), 72 deletions(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 106eb938a..b581dfacf 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1278,6 +1278,121 @@ function buildMockConfigSurface(pubkey: string): { }, }; + // Live runtime override — a persona-linked agent whose session model was + // switched at runtime. The live model rides over the persona baseline as a + // secondary value WITHOUT strikethrough (the headline runtimeOverride render). + const runtimeOverrideSurface = { + runtimeId: "goose", + runtimeLabel: "Goose", + isPreSpawn: false, + normalized: { + model: { + value: "claude-opus-4-20250514", + origin: "runtimeOverride", + isWritable: true, + writeVia: { type: "acpSetSessionModel" }, + overriddenValue: "gpt-4o", + overriddenOrigin: "personaDefault", + }, + provider: { + value: "anthropic", + origin: "acpConfigOption", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "auto", + origin: "envVar", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: "high", + origin: "configFile", + isWritable: true, + writeVia: { + type: "gooseNativeConfigWrite", + configKey: "GOOSE_THINKING_EFFORT", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + sources: { + acpNative: "available", + acpConfigOptions: "available", + envVars: "available", + configFile: "available", + configFilePath: "~/.config/goose/config.yaml", + }, + }; + + // Mixed-provenance showcase — every top-level row carries a DIFFERENT origin + // so the panel witnesses four distinct provenance sentences in one frame: + // "Set in Buzz", "Inherited from persona", "From config file (...)", and + // "From environment variable (...)". + const multiOriginSurface = { + runtimeId: "goose", + runtimeLabel: "Goose", + isPreSpawn: false, + normalized: { + model: { + value: "gpt-4o", + origin: "buzzExplicit", + isWritable: true, + writeVia: { type: "acpSetSessionModel" }, + overriddenValue: null, + overriddenOrigin: null, + }, + provider: { + value: "openai", + origin: "personaDefault", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "auto", + origin: "envVar", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: "medium", + origin: "configFile", + isWritable: true, + writeVia: { + type: "gooseNativeConfigWrite", + configKey: "GOOSE_THINKING_EFFORT", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + sources: { + acpNative: "available", + acpConfigOptions: "available", + envVars: "available", + configFile: "available", + configFilePath: "~/.config/goose/config.yaml", + }, + }; + // Map well-known test pubkeys to specific fixtures const PUBKEY_CLAUDE = "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; @@ -1285,6 +1400,11 @@ function buildMockConfigSurface(pubkey: string): { "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; const PUBKEY_CODEX = "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; + const PUBKEY_RUNTIME_OVERRIDE = + "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e"; + // Synthetic agent for the multi-origin provenance showcase (not a TEST_IDENTITY). + const PUBKEY_MULTI_ORIGIN = + "abc1230000000000000000000000000000000000000000000000000000000def"; switch (pubkey) { case PUBKEY_CLAUDE: @@ -1293,6 +1413,10 @@ function buildMockConfigSurface(pubkey: string): { return preSpawnSurface; case PUBKEY_CODEX: return codexSurface; + case PUBKEY_RUNTIME_OVERRIDE: + return runtimeOverrideSurface; + case PUBKEY_MULTI_ORIGIN: + return multiOriginSurface; default: return gooseSurface; } diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts index 502aecc5f..88e85e881 100644 --- a/desktop/tests/e2e/config-bridge-screenshots.spec.ts +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -6,23 +6,30 @@ const SHOTS = "test-results/config-bridge"; // Use well-known test pubkeys that map to distinct config surface fixtures const GOOSE_PUBKEY = TEST_IDENTITIES.tyler.pubkey; -const CLAUDE_PUBKEY = TEST_IDENTITIES.alice.pubkey; const PRESPAWN_PUBKEY = TEST_IDENTITIES.bob.pubkey; -const CODEX_PUBKEY = TEST_IDENTITIES.charlie.pubkey; +const RUNTIME_OVERRIDE_PUBKEY = TEST_IDENTITIES.outsider.pubkey; +// Synthetic agent whose config surface mixes four distinct provenance origins +// (matches PUBKEY_MULTI_ORIGIN in e2eBridge buildMockConfigSurface). +const MULTI_ORIGIN_PUBKEY = + "abc1230000000000000000000000000000000000000000000000000000000def"; const MANAGED_AGENTS = [ { pubkey: GOOSE_PUBKEY, name: "Goose Agent", status: "running" as const }, - { - pubkey: CLAUDE_PUBKEY, - name: "Claude Code Agent", - status: "running" as const, - }, { pubkey: PRESPAWN_PUBKEY, name: "Pre-Spawn Agent", status: "stopped" as const, }, - { pubkey: CODEX_PUBKEY, name: "Codex Agent", status: "running" as const }, + { + pubkey: RUNTIME_OVERRIDE_PUBKEY, + name: "Runtime Override Agent", + status: "running" as const, + }, + { + pubkey: MULTI_ORIGIN_PUBKEY, + name: "Multi-Origin Agent", + status: "running" as const, + }, ]; async function waitForInvokeBridge(page: import("@playwright/test").Page) { @@ -102,102 +109,119 @@ async function expandAgent( }); } +// Settle any in-flight Radix/expand animations on the agent row before a +// capture so screenshots are deterministic (team-management-screenshots pattern). +async function settleAnimations( + page: import("@playwright/test").Page, + pubkey: string, +) { + await page + .getByTestId(`managed-agent-${pubkey}`) + .evaluate((el) => + Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), + ); +} + test.describe("config bridge screenshots", () => { test.use({ viewport: { width: 1280, height: 900 } }); - test("01 — goose full config panel", async ({ page }) => { + test("01 — folded config panel", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); await openAgentsView(page); await expandAgent(page, GOOSE_PUBKEY); - const logRow = page - .getByTestId(`managed-agent-${GOOSE_PUBKEY}`) - .getByTestId("managed-agent-log-row"); - await logRow.screenshot({ path: `${SHOTS}/01-goose-full-config.png` }); - }); - - test("02 — claude ACP config", async ({ page }) => { - await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); - await openAgentsView(page); - await expandAgent(page, CLAUDE_PUBKEY); + // The folded config panel: provenance sentences inline under each value, + // no origin badges, no sources footer. + const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); + await expect(agentRow.getByText("Set in Buzz")).toBeVisible(); + await settleAnimations(page, GOOSE_PUBKEY); - const logRow = page - .getByTestId(`managed-agent-${CLAUDE_PUBKEY}`) - .getByTestId("managed-agent-log-row"); - await logRow.screenshot({ path: `${SHOTS}/02-claude-acp-config.png` }); + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/01-folded-config-panel.png` }); }); - test("03 — pre-spawn state", async ({ page }) => { + test("02 — live runtime override", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); await openAgentsView(page); - await expandAgent(page, PRESPAWN_PUBKEY); + await expandAgent(page, RUNTIME_OVERRIDE_PUBKEY); + + // The headline new behavior: a runtimeOverride model shows the live model, + // the persona baseline as a NON-struck secondary value, and the + // "Live override (this session only)" sentence. + const agentRow = page.getByTestId( + `managed-agent-${RUNTIME_OVERRIDE_PUBKEY}`, + ); + await expect( + agentRow.getByText("Live override (this session only)"), + ).toBeVisible(); + await expect(agentRow.getByText("gpt-4o", { exact: true })).toBeVisible(); + await settleAnimations(page, RUNTIME_OVERRIDE_PUBKEY); - const logRow = page - .getByTestId(`managed-agent-${PRESPAWN_PUBKEY}`) - .getByTestId("managed-agent-log-row"); - await logRow.screenshot({ path: `${SHOTS}/03-pre-spawn-state.png` }); + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/02-live-runtime-override.png` }); }); - test("04 — override visibility", async ({ page }) => { + test("03 — provenance sentences", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); await openAgentsView(page); - await expandAgent(page, GOOSE_PUBKEY); + await expandAgent(page, MULTI_ORIGIN_PUBKEY); - // The goose fixture has model overridden from configFile by buzzExplicit. - // Capture the config section (below the log content). - const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); - const configSection = agentRow.locator("text=Configuration").locator(".."); - await configSection.screenshot({ - path: `${SHOTS}/04-override-visibility.png`, - }); + // Each row carries a DIFFERENT inline provenance sentence so the frame + // witnesses multiple distinct origins at once: "Set in Buzz" (model), + // "Inherited from persona" (provider), "From environment variable + // (GOOSE_MODE)" (mode), and "From config file (...)" (thinking/effort). + const agentRow = page.getByTestId(`managed-agent-${MULTI_ORIGIN_PUBKEY}`); + await expect(agentRow.getByText("Set in Buzz")).toBeVisible(); + await expect(agentRow.getByText("Inherited from persona")).toBeVisible(); + await expect( + agentRow.getByText("From environment variable (GOOSE_MODE)"), + ).toBeVisible(); + await expect( + agentRow + .getByText("From config file (~/.config/goose/config.yaml)") + .first(), + ).toBeVisible(); + await settleAnimations(page, MULTI_ORIGIN_PUBKEY); + + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/03-provenance-sentences.png` }); }); - test("05 — advanced section expanded", async ({ page }) => { + test("04 — pre-spawn state", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); await openAgentsView(page); - await expandAgent(page, GOOSE_PUBKEY); - - // Click the Advanced chevron button - const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); - const advancedButton = agentRow.getByRole("button", { name: /Advanced/i }); - await advancedButton.click(); + await expandAgent(page, PRESPAWN_PUBKEY); - // Wait for advanced fields to appear - await expect(agentRow.locator("text=Extension: developer")).toBeVisible(); + // ACP-only fields show "Available after agent starts" before spawn. + const agentRow = page.getByTestId(`managed-agent-${PRESPAWN_PUBKEY}`); + await expect( + agentRow.getByText("Available after agent starts").first(), + ).toBeVisible(); + await settleAnimations(page, PRESPAWN_PUBKEY); - const logRow = agentRow.getByTestId("managed-agent-log-row"); - await logRow.screenshot({ path: `${SHOTS}/05-advanced-expanded.png` }); + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/04-pre-spawn-state.png` }); }); - test("06 — config provenance", async ({ page }) => { + test("05 — advanced expanded", async ({ page }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); await openAgentsView(page); await expandAgent(page, GOOSE_PUBKEY); - // Each config value carries an inline provenance sentence below it. - // The goose fixture's model is buzzExplicit ("Set in Buzz"); its provider - // and thinking effort are configFile, so multiple rows render the same - // "From config file (...)" sentence — assert the first match. const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); - await expect(agentRow.getByText("Set in Buzz")).toBeVisible(); - await expect( - agentRow - .getByText("From config file (~/.config/goose/config.yaml)") - .first(), - ).toBeVisible(); - - const logRow = agentRow.getByTestId("managed-agent-log-row"); - await logRow.screenshot({ path: `${SHOTS}/06-config-provenance.png` }); - }); + const advancedButton = agentRow.getByRole("button", { name: /Advanced/i }); + await advancedButton.click(); - test("07 — codex dual mode", async ({ page }) => { - await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); - await openAgentsView(page); - await expandAgent(page, CODEX_PUBKEY); + // Wait for advanced fields to appear, then settle the expand animation. + await expect(agentRow.getByText("Extension: developer")).toBeVisible(); + await settleAnimations(page, GOOSE_PUBKEY); - const logRow = page - .getByTestId(`managed-agent-${CODEX_PUBKEY}`) - .getByTestId("managed-agent-log-row"); - await logRow.screenshot({ path: `${SHOTS}/07-codex-dual-mode.png` }); + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/05-advanced-expanded.png` }); }); }); From 6b06c80c8fff5528015007b82ad5ca5b176eb052 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 24 Jun 2026 13:04:59 -0400 Subject: [PATCH 11/14] fix(desktop): integrate config section into profile panel, add value tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent side-panel Configuration section rendered with AgentConfigPanel's own dense header (small inline icon, muted heading) inside the ProfileFieldGroup card, so it read as bolted on rather than part of the card. Re-skin the section header to the panel's ProfileFieldRow convention (circular icon badge, px-4 row rhythm, foreground label) so it reads as one more section; the divider stays. Long config values truncate correctly but had no way to reveal the full text. Add a native title attribute carrying the untruncated value on the truncated NormalizedRow/AdvancedRow values, guarded so null placeholders get no tooltip — matching the existing ProfileFieldRow title convention. Covers both the side panel and the Agents nav menu since both embed the same AgentConfigPanel. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/features/agents/ui/AgentConfigPanel.tsx | 10 ++++++++-- .../features/profile/ui/UserProfilePanelSections.tsx | 10 ++++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8cf9da0b9..50adbe71b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -89,7 +89,7 @@ const overrides = new Map([ // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge // panel — a small overage from load-bearing UI plumbing, not generic debt // growth. Approved override; still queued to split with the rest of this list. - ["src/features/profile/ui/UserProfilePanelSections.tsx", 1002], + ["src/features/profile/ui/UserProfilePanelSections.tsx", 1004], // useDueReminderBadgeCount hook call + sum to wire due-reminder count into // the Inbox nav badge — a small overage from load-bearing badge plumbing, // not generic debt growth. Approved override; still queued to split. diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 9c30dd834..8775c816a 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -77,7 +77,10 @@ function NormalizedRow({ return (
{label}
-
+
{isPreSpawn && isAcpOnly ? ( Available after agent starts @@ -122,7 +125,10 @@ function AdvancedRow({ return (
{field.label}
-
+
{field.value ?? ( )} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index f7b99ac98..87a2c8c86 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -248,11 +248,13 @@ export function ProfileSummaryView({ footer={ isBot && isOwner === true && managedAgent !== undefined ? (
-
- -

+
+ + + + Configuration -

+
Date: Wed, 24 Jun 2026 13:18:47 -0400 Subject: [PATCH 12/14] fix(desktop): show full override text on hover in agent config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truncated NormalizedRow override span (config-file system prompt in the dual-prompt case) rendered inside the truncate container with no title, so its full text was unreadable on hover — the same gap the primary value tooltip already closes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/ui/AgentConfigPanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 8775c816a..8c61a6dcf 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -97,6 +97,7 @@ function NormalizedRow({ // secondary value, not struck through. field.origin !== "runtimeOverride" && "line-through", )} + title={field.overriddenValue ?? undefined} > {field.overriddenValue} From b87efb6a03388f6aeee7c7229ebc19946b6525ea Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 24 Jun 2026 22:33:59 -0400 Subject: [PATCH 13/14] fix(desktop): add missing ManagedAgentRecord fields to test helpers Main's d256935c3 added agent_command_override, persona_source_version, and provider to ManagedAgentRecord. Two test fixture initializers on this branch were missing them, causing E0063 compile errors in CI. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/agent_config.rs | 3 +++ desktop/src-tauri/src/managed_agents/config_bridge/reader.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 8bd642e74..2c45ab249 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -488,6 +488,9 @@ mod tests { respond_to: RespondTo::OwnerOnly, respond_to_allowlist: vec![], relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, } } 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 18576d4f3..c6d401710 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -558,6 +558,9 @@ mod tests { respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, respond_to_allowlist: vec![], relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, } } From 7f9626635b23c6a5ad2417419fc4b38151b1b2a7 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 24 Jun 2026 22:56:21 -0400 Subject: [PATCH 14/14] test(e2e): add profile side panel config screenshot (shot 06) Opens the user-profile-panel for a managed agent by clicking its avatar in the #agents channel, waits for the Configuration section to render, and captures the full panel. Covers the isBot && isOwner path that renders the Configuration footer inside ProfileSummaryView. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../e2e/config-bridge-screenshots.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts index 88e85e881..62c565b6d 100644 --- a/desktop/tests/e2e/config-bridge-screenshots.spec.ts +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -224,4 +224,49 @@ test.describe("config bridge screenshots", () => { .getByTestId("managed-agent-log-row") .screenshot({ path: `${SHOTS}/05-advanced-expanded.png` }); }); + + test("06 — profile side panel — Configuration section", async ({ page }) => { + // charlie (554cef…) is the well-known test pubkey that the mock bridge + // seeds as a bot owned by the test viewer, so isBot + isOwner + managedAgent + // are all true — the Configuration section renders in the profile panel. + await installMockBridge(page, { + managedAgents: [ + { + pubkey: + "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea", + name: "Charlie", + status: "running" as const, + channelNames: ["agents"], + }, + ], + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForInvokeBridge(page); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + // Click the agent avatar in the message row to open the profile side panel. + await page + .getByTestId("message-row") + .last() + .getByRole("button") + .first() + .click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + + // Wait for the Configuration section to render and scroll it into view so + // it is fully visible before capture. + const configHeading = panel.getByText("Configuration"); + await expect(configHeading).toBeVisible({ timeout: 10_000 }); + await configHeading.scrollIntoViewIfNeeded(); + + // Settle any in-flight animations before capture. + await panel.evaluate((el) => + Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), + ); + + await panel.screenshot({ path: `${SHOTS}/06-profile-side-panel-config.png` }); + }); });