diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0fb34ad2..c0483d3d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -350,8 +350,10 @@ jobs: wait_healthy "Redis" "buzz-redis" wait_healthy "Typesense" "buzz-typesense" wait_healthy "MinIO" "buzz-minio" + - name: Create pgschema plan database + run: PGPASSWORD=buzz_dev createdb -h localhost -p 5432 -U buzz pgschema_plan - name: Apply database schema - run: ./bin/pgschema apply --file schema/schema.sql --auto-approve + run: ./bin/pgschema apply --file schema/schema.sql --auto-approve --plan-host localhost --plan-port 5432 --plan-user buzz --plan-password buzz_dev --plan-db pgschema_plan env: PGHOST: localhost PGPORT: "5432" diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 7f39639d0..995a61d84 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -462,6 +462,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 d9b0ee127..c08b5854e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "**/channel-controls-screenshots.spec.ts", "**/team-management-screenshots.spec.ts", "**/active-turn-screenshots.spec.ts", + "**/config-bridge-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/video-attachment.spec.ts", "**/mentions.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 28af88229..a8f9e0bd8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,12 +30,12 @@ const rules = [ // Do not add to this list; split the file instead. Remove each entry as its // file is broken up. Tracked as a follow-up. const overrides = new Map([ - ["src-tauri/src/commands/agents.rs", 1294], + ["src-tauri/src/commands/agents.rs", 1350], ["src-tauri/src/managed_agents/nest.rs", 1420], ["src-tauri/src/managed_agents/runtime.rs", 1940], ["src-tauri/src/managed_agents/personas.rs", 1080], ["src-tauri/src/managed_agents/persona_card.rs", 1050], - ["src/shared/api/tauri.ts", 1196], + ["src/shared/api/tauri.ts", 1250], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6f84c7528..6db96bf4c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -901,6 +901,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 c0001c799..f21e720d3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -58,6 +58,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 a20dc815e..5112b4fb2 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. @@ -81,6 +85,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), @@ -105,6 +110,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 37bad60f8..a86e06a9c 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -156,18 +156,27 @@ fn build_deploy_payload( 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 effective model/provider from the persona's structured fields. - // Agent record's model takes precedence (user override via UI). - let (effective_model, effective_provider) = if let Some(ref pid) = record.persona_id { + // 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 for deploy payload model resolution: {e}") + format!( + "failed to load personas while building deploy payload for persona `{pid}`: {e}" + ) })?; - let persona = personas.iter().find(|p| p.id == *pid); - let model = record - .model - .clone() - .or_else(|| persona.and_then(|p| p.model.clone())); - let provider = persona.and_then(|p| p.provider.clone()); + 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) @@ -182,6 +191,9 @@ fn build_deploy_payload( "agent_args": &record.agent_args, "system_prompt": &record.system_prompt, "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, @@ -1033,6 +1045,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() @@ -1082,10 +1095,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 559577bf7..3a630e69e 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; @@ -27,6 +28,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 10a8f98a0..d76205169 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -758,6 +758,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 88f5c2d6a..357b49b10 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 f10376a27..56808cc5d 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,4 +1,5 @@ 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 02b9129ba..7b3b9c480 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, @@ -549,6 +550,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 ee8483dc8..591641c5b 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 { @@ -159,6 +159,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 3c7310658..62b6b59fa 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -35,6 +35,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"; @@ -207,6 +208,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 783b8db54..942f45805 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 11e25da4d..e19e9f040 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -43,6 +43,9 @@ import type { CommandAvailability, InstallRuntimeResult, OpenDmInput, + RuntimeConfigSurface, + WriteConfigFieldRequest, + WriteConfigResult, } from "@/shared/api/types"; type RawIdentity = { @@ -1119,6 +1122,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 653a1be99..0198ddf49 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -443,6 +443,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 2a99941fb..a7abdc2b5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -942,6 +942,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"; @@ -6277,6 +6575,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` }); + }); +});