diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 6cd705d6c..88a072f6f 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1215,6 +1215,41 @@ pub fn resolve_model_switch_method( None } +/// Whether `desired_model` appears in pre-extracted catalog halves. +/// +/// Mirrors [`resolve_model_switch_method`]'s match, but operates on the +/// already-extracted `configOptions` (model category) and `models` state that +/// [`AgentModelCapabilities`](crate::pool::AgentModelCapabilities) caches — the +/// idle-path pre-cancel guard has those halves, not the full `session/new` JSON. +pub fn model_in_catalog( + config_options: &[serde_json::Value], + available_models: Option<&serde_json::Value>, + desired_model: &str, +) -> bool { + let in_config_options = config_options.iter().any(|config_opt| { + config_opt + .get("options") + .and_then(|v| v.as_array()) + .is_some_and(|options| { + options + .iter() + .any(|opt| opt.get("value").and_then(|v| v.as_str()) == Some(desired_model)) + }) + }); + if in_config_options { + return true; + } + + available_models + .and_then(|models| models.get("availableModels")) + .and_then(|v| v.as_array()) + .is_some_and(|available| { + available + .iter() + .any(|model| model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model)) + }) +} + // ─── Drop: kill child process ───────────────────────────────────────────────── impl Drop for AcpClient { @@ -1783,6 +1818,58 @@ mod tests { ); } + // ── model_in_catalog tests ──────────────────────────────────────────── + + #[test] + fn model_in_catalog_true_when_in_config_options() { + let config_options = vec![serde_json::json!({ + "configId": "model", + "category": "model", + "options": [ + { "value": "claude-sonnet-4-20250514" }, + { "value": "claude-opus-4-20250514" } + ] + })]; + assert!(super::model_in_catalog( + &config_options, + None, + "claude-opus-4-20250514" + )); + } + + #[test] + fn model_in_catalog_true_when_in_available_models() { + let available = serde_json::json!({ + "currentModelId": "gpt-5", + "availableModels": [ + { "modelId": "gpt-5" }, + { "modelId": "o3-pro" } + ] + }); + assert!(super::model_in_catalog(&[], Some(&available), "o3-pro")); + } + + #[test] + fn model_in_catalog_false_when_absent_from_both_halves() { + let config_options = vec![serde_json::json!({ + "configId": "model", + "options": [{ "value": "claude-sonnet-4-20250514" }] + })]; + let available = serde_json::json!({ + "availableModels": [{ "modelId": "gpt-5" }] + }); + assert!(!super::model_in_catalog( + &config_options, + Some(&available), + "nonexistent-model" + )); + } + + #[test] + fn model_in_catalog_false_when_both_halves_empty() { + assert!(!super::model_in_catalog(&[], None, "anything")); + } + // ── Error variant display ───────────────────────────────────────────── #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 5da767bc5..98eaef0c6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -29,8 +29,8 @@ use filter::SubscriptionRule; use futures_util::FutureExt; use nostr::{PublicKey, ToBech32}; use pool::{ - AgentPool, ControlSignal, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, - SessionState, + AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome, + PromptResult, PromptSource, SessionState, }; use queue::{EventQueue, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; @@ -718,11 +718,25 @@ fn handle_relay_observer_control_event( }; let command_type = payload.get("type").and_then(|value| value.as_str()); - if command_type != Some("cancel_turn") { - tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); - return; + match command_type { + Some("cancel_turn") => { + handle_cancel_turn_control(&payload, pool, observer); + } + Some("switch_model") => { + handle_switch_model_control(&payload, pool, observer); + } + _ => { + tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); + } } +} +/// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. +fn handle_cancel_turn_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { let Some(channel_id) = payload .get("channelId") .and_then(|value| value.as_str()) @@ -751,6 +765,83 @@ fn handle_relay_observer_control_event( } } +/// Handle a `switch_model` control frame (Phase 3a, Option ii). +/// +/// Busy path: deliver `SwitchModel` over the in-flight task's oneshot — the +/// task cancels the turn, sets `desired_model`, and requeues the batch so it +/// re-runs on a fresh session under the new model. A catalog miss surfaces +/// post-cancel via `create_session_and_apply_model` (the turn restarts on the +/// unchanged model + an `unsupported_model` result). +/// +/// Idle path: validate against the cached catalog *before* invalidating +/// (pre-cancel guard), then set `desired_model` + invalidate. The override +/// takes visible effect on the agent's next turn. +fn handle_switch_model_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { + let Some(channel_id) = payload + .get("channelId") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()) + else { + tracing::warn!("observer switch_model control frame missing valid channelId"); + return; + }; + let Some(model_id) = payload.get("modelId").and_then(|value| value.as_str()) else { + tracing::warn!("observer switch_model control frame missing modelId"); + return; + }; + + // A turn is in flight for this channel iff a task_map entry exists. The + // agent is moved out of the pool during a turn, so the control oneshot is + // the only reachable lever; an idle channel has no such entry. + let turn_in_flight = pool + .task_map() + .values() + .any(|m| m.channel_id == Some(channel_id)); + + let status = if turn_in_flight { + // Busy path: deliver over the oneshot. `false` means the oneshot was + // already consumed this turn (a prior cancel/interrupt) — the turn is + // already ending, so the switch cannot land on it. + if signal_in_flight_task( + pool, + channel_id, + ControlSignal::SwitchModel(model_id.to_string()), + ) { + "sent" + } else { + "turn_ending" + } + } else { + // Idle path: validate against the cached catalog before invalidating. + match pool.switch_idle_agent_model(channel_id, model_id) { + IdleSwitchResult::Switched => "switched", + IdleSwitchResult::UnsupportedModel => "unsupported_model", + IdleSwitchResult::NoIdleAgent => "no_active_turn", + } + }; + + if let Some(observer) = observer { + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: Some(channel_id.to_string()), + session_id: None, + turn_id: None, + }, + serde_json::json!({ + "type": "switch_model", + "status": status, + "modelId": model_id, + }), + ); + } +} + /// Maximum crashes in a 60-second window before a slot's circuit opens. const CIRCUIT_BREAKER_THRESHOLD: usize = 3; /// Window for circuit-breaker crash counting. @@ -1046,6 +1137,7 @@ async fn tokio_main() -> Result<()> { state: SessionState::default(), model_capabilities: None, desired_model: config.model.clone(), + model_overridden: false, protocol_version, })); } @@ -1482,6 +1574,7 @@ async fn tokio_main() -> Result<()> { state: SessionState::default(), model_capabilities: None, desired_model: config.model.clone(), + model_overridden: false, protocol_version, }; pool.return_agent(agent); @@ -2161,8 +2254,8 @@ fn signal_in_flight_task( if let Some(meta) = entry { if let Some(tx) = meta.control_tx.take() { - let _ = tx.send(mode); tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task"); + let _ = tx.send(mode); return true; } } @@ -3487,6 +3580,7 @@ mod error_outcome_emission_tests { state: Default::default(), model_capabilities: None, desired_model: None, + model_overridden: false, // Error branches under test never read this; 1 is the legacy // non-systemPrompt path, the simplest valid value. protocol_version: 1, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6f82f2a24..53f729ea8 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -29,8 +29,8 @@ use tokio::time::timeout; use uuid::Uuid; use crate::acp::{ - extract_model_config_options, extract_model_state, resolve_model_switch_method, AcpClient, - AcpError, McpServer, ModelSwitchMethod, StopReason, + extract_model_config_options, extract_model_state, model_in_catalog, + resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, }; use crate::config::{DedupMode, PermissionMode}; use crate::observer; @@ -135,6 +135,11 @@ pub struct OwnedAgent { pub model_capabilities: Option, /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. pub desired_model: Option, + /// Whether `desired_model` was set by a live `SwitchModel` control signal + /// (as opposed to being derived from config/persona at spawn). Used by the + /// desktop reader to distinguish a genuine runtime override from a stale + /// session whose persona model was edited. Reset on spawn/restart. + pub model_overridden: bool, /// Protocol version reported by the agent in its initialize response. /// Agents declaring >= 2 support `systemPrompt` in session/new. pub protocol_version: u32, @@ -176,15 +181,24 @@ pub enum PromptSource { fn apply_completed_before_control_signal( state: &mut SessionState, source: &PromptSource, - control_signal: ControlSignal, + control_signal: &ControlSignal, ) { - if control_signal == ControlSignal::Rotate { + // Rotate and SwitchModel both invalidate so the next turn creates a fresh + // session. For SwitchModel the caller has already set `desired_model`, so + // the fresh session applies the new model on its next creation. + if matches!( + control_signal, + ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ) { state.invalidate(source); } } /// Control signal for an in-flight channel turn. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when +/// a value is needed after a move, or match by reference. +#[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. Cancel, @@ -193,6 +207,23 @@ pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. The session is /// invalidated just like cancel; the next turn creates a fresh session. Rotate, + /// Switch the agent's model, then requeue the triggering batch so it + /// re-runs on a fresh session under the new model. The model lands by + /// setting `OwnedAgent::desired_model` before invalidation; the requeued + /// turn re-creates the session and re-applies `desired_model`. Runtime-only + /// — never persisted, gone on restart/respawn. + SwitchModel(String), +} + +impl ControlSignal { + /// Whether this signal requeues its triggering batch (vs dropping it). + /// `Interrupt` and `SwitchModel` requeue; `Cancel`/`Rotate` drop. + fn requeues(&self) -> bool { + matches!( + self, + ControlSignal::Interrupt | ControlSignal::SwitchModel(_) + ) + } } /// Outcome of a prompt task. @@ -403,9 +434,64 @@ impl AgentPool { } count } + + /// Idle-path model switch: set `desired_model` on the idle agent for + /// `channel_id` and invalidate its session so the next turn re-creates the + /// session under the new model. + /// + /// Pre-cancel guard: the desired model is validated against the agent's + /// cached catalog *before* the session is invalidated, so an unsupported + /// pick is rejected without disturbing the existing session. + /// + /// Returns [`IdleSwitchResult`] describing what happened. The model does not + /// take effect — and the panel does not reflect it — until the agent next + /// runs a turn (no live session exists to re-emit `session_config_captured` + /// from an idle agent). This lag is intentional: faking the emit would + /// surface an override the session has not actually applied. + pub fn switch_idle_agent_model( + &mut self, + channel_id: Uuid, + model_id: &str, + ) -> IdleSwitchResult { + let Some(agent) = self + .agents + .iter_mut() + .flatten() + .find(|a| a.state.sessions.contains_key(&channel_id)) + else { + return IdleSwitchResult::NoIdleAgent; + }; + + // Pre-cancel guard against the cached catalog. None = catalog not yet + // populated (no session ever created); defer validation to apply time. + if let Some(caps) = agent.model_capabilities.as_ref() { + if !model_in_catalog( + &caps.config_options_raw, + caps.available_models_raw.as_ref(), + model_id, + ) { + return IdleSwitchResult::UnsupportedModel; + } + } + + agent.desired_model = Some(model_id.to_string()); + agent.model_overridden = true; + agent.state.invalidate_channel(&channel_id); + IdleSwitchResult::Switched + } } -// ── run_prompt_task ─────────────────────────────────────────────────────────── +/// Outcome of [`AgentPool::switch_idle_agent_model`]. +#[derive(Debug, PartialEq, Eq)] +pub enum IdleSwitchResult { + /// `desired_model` set and the channel session invalidated. + Switched, + /// Desired model is not in the agent's cached catalog — pick rejected, + /// session untouched. + UnsupportedModel, + /// No idle agent available (all checked out / none spawned). + NoIdleAgent, +} /// Timeout for a single pre-prompt context fetch attempt (thread/DM history). /// Each call gets this budget; with one retry the total worst-case is @@ -464,6 +550,17 @@ 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), + "modelOverridden": agent.model_overridden, + }), + ); + // 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) { @@ -475,6 +572,18 @@ async fn create_session_and_apply_model( target: "pool::model", "desired model {desired} not found in agent's available models — proceeding with agent default" ); + // Surface the miss so the desktop ModelPicker can reject a live + // pick rather than silently no-op. On the busy path the turn has + // already been cancelled+requeued by the time we get here, so the + // turn restarts on the unchanged model and the user is told no. + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "unsupported_model", + "modelId": desired, + }), + ); } } } @@ -1224,6 +1333,14 @@ pub async fn run_prompt_task( _ = &mut liveness => unreachable!("liveness future never resolves"), mode = rx => { let control_signal = mode.unwrap_or(ControlSignal::Cancel); + // Land the model switch before any cancel/requeue work: setting + // `desired_model` here means the fresh session created by the + // requeued turn (busy) or the next turn (already-completed) + // applies the new model. Runtime-only — never persisted. + if let ControlSignal::SwitchModel(ref model_id) = control_signal { + agent.desired_model = Some(model_id.clone()); + agent.model_overridden = true; + } // Control signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. if agent.acp.has_in_flight_prompt() { @@ -1239,9 +1356,10 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); agent.state.invalidate(&source); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1253,9 +1371,10 @@ pub async fn run_prompt_task( } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1268,9 +1387,10 @@ pub async fn run_prompt_task( Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout) => { // Cancel drain timed out — agent state uncertain. agent.state.invalidate(&source); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1282,9 +1402,10 @@ pub async fn run_prompt_task( } Err(e) => { agent.state.invalidate(&source); - let retry_batch = match control_signal { - ControlSignal::Interrupt => requeue_batch_if_queue(&ctx, batch), - ControlSignal::Cancel | ControlSignal::Rotate => None, + let retry_batch = if control_signal.requeues() { + requeue_batch_if_queue(&ctx, batch) + } else { + None }; let _ = result_tx.send(PromptResult { agent, @@ -1310,10 +1431,13 @@ pub async fn run_prompt_task( // and last_prompt_id was cleared by the success path. // // MUST send a PromptResult or the main loop deadlocks. - if control_signal == ControlSignal::Rotate { + if matches!( + control_signal, + ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ) { tracing::debug!( target: "pool::prompt", - "rotate signal arrived but turn already completed — invalidating session" + "rotate/switch signal arrived but turn already completed — invalidating session" ); } else { tracing::debug!( @@ -1324,7 +1448,7 @@ pub async fn run_prompt_task( apply_completed_before_control_signal( &mut agent.state, &source, - control_signal, + &control_signal, ); let _ = result_tx.send(PromptResult { agent, @@ -2904,7 +3028,7 @@ mod tests { apply_completed_before_control_signal( &mut s, &PromptSource::Channel(ch_a), - ControlSignal::Rotate, + &ControlSignal::Rotate, ); assert!(!s.sessions.contains_key(&ch_a)); @@ -2925,7 +3049,7 @@ mod tests { apply_completed_before_control_signal( &mut s, &PromptSource::Channel(ch_a), - ControlSignal::Cancel, + &ControlSignal::Cancel, ); assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a"); @@ -3048,6 +3172,38 @@ mod tests { assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); } + // ── ControlSignal::SwitchModel (Phase 3a, Option ii) ───────────────────── + + #[test] + fn test_switch_model_after_natural_completion_invalidates_channel_state() { + let (mut s, ch_a, ch_b) = make_state(); + + // SwitchModel must invalidate just like Rotate so the requeued turn + // re-creates a fresh session that re-applies the new desired_model. + apply_completed_before_control_signal( + &mut s, + &PromptSource::Channel(ch_a), + &ControlSignal::SwitchModel("gpt-5".into()), + ); + + assert!(!s.has_channel_state(&ch_a)); + // ch_b untouched — the switch is channel-scoped. + assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); + } + + #[test] + fn test_requeues_true_for_interrupt_and_switch_model() { + assert!(ControlSignal::Interrupt.requeues()); + assert!(ControlSignal::SwitchModel("any".into()).requeues()); + } + + #[test] + fn test_requeues_false_for_cancel_and_rotate() { + assert!(!ControlSignal::Cancel.requeues()); + assert!(!ControlSignal::Rotate.requeues()); + } + // ── turn liveness emission ─────────────────────────────────────────────── // `run_turn_liveness` is raced against a "prompt" future the same way // `run_prompt_task` does it: the prompt wins the select and the liveness diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c431eba7..d31d0e987 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ "**/active-turn-screenshots.spec.ts", "**/active-turn-resilience-screenshots.spec.ts", "**/profile-active-turn-screenshots.spec.ts", + "**/config-bridge-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/video-attachment.spec.ts", "**/spoiler.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 79cd0e664..50adbe71b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,7 +34,7 @@ const overrides = new Map([ // read-time relay-URL workspace fallback while keeping the create-time env // pin (the credential-leak guard). Load-bearing feature growth from the // rebase, queued to split with the rest of this list. - ["src-tauri/src/commands/agents.rs", 1350], + ["src-tauri/src/commands/agents.rs", 1375], // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned // outside NEST_DIRS (it may be a symlink), so it needs its own create + // chmod-only-when-real-dir handling plus integration test coverage. The @@ -60,13 +60,13 @@ const overrides = new Map([ // harness-persona-sync `harnessOverride` create-input bit — load-bearing // parameter plumbing, not generic debt growth. Approved override; still // queued to split. - ["src/shared/api/tauri.ts", 1209], + ["src/shared/api/tauri.ts", 1235], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); // types.rs adds the persona/instance harness fields. Load-bearing, not // generic debt. - ["src-tauri/src/managed_agents/discovery.rs", 1043], + ["src-tauri/src/managed_agents/discovery.rs", 1064], ["src-tauri/src/managed_agents/types.rs", 1037], // migration_tests.rs carries the harness-sync migration coverage plus the // patch_json_records owner-only writeback regression test (SECURITY.md:90 @@ -80,12 +80,16 @@ const overrides = new Map([ // syncs team-dir edits before all personas.json readers; run_event_sync // signs the persona/team retention events post-identity) layered on top of // main's growth. Load-bearing feature growth, queued to split with the list. - ["src-tauri/src/lib.rs", 1026], + ["src-tauri/src/lib.rs", 1029], // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop // already here) for the single-toggle mark-read/unread menu item — a small // overage from load-bearing per-message plumbing, not generic debt growth. // Approved override; still queued to split with the rest of this list. ["src/features/messages/ui/MessageThreadPanel.tsx", 1006], + // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge + // panel — a small overage from load-bearing UI plumbing, not generic debt + // growth. Approved override; still queued to split with the rest of this list. + ["src/features/profile/ui/UserProfilePanelSections.tsx", 1004], // useDueReminderBadgeCount hook call + sum to wire due-reminder count into // the Inbox nav badge — a small overage from load-bearing badge plumbing, // not generic debt growth. Approved override; still queued to split. @@ -94,7 +98,7 @@ const overrides = new Map([ // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at // 893 lines, not generic debt growth. Approved override; still queued to split. - ["src-tauri/src/app_state.rs", 1012], + ["src-tauri/src/app_state.rs", 1033], ]); await runFileSizeCheck({ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 34180c95f..34b774f94 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "tokio-util", + "toml 0.8.2", "url", "uuid", "windows-sys 0.61.2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3aced7026..aa7a3a953 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +toml = "0.8" nostr = { version = "0.44", features = ["nip44"] } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index c63ecb2ff..cce67cf5b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -10,6 +10,7 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::ManagedAgentProcess; pub struct AppState { @@ -33,6 +34,9 @@ pub struct AppState { pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, + /// Cached ACP session config from running agents, keyed by agent pubkey. + /// Populated when the harness emits `session_config_captured` observer events. + pub session_config_cache: Mutex>, /// IOKit power assertion state — prevents idle sleep while agents run. pub prevent_sleep: Arc>, /// In-process mesh-llm node started by Buzz Desktop. @@ -92,6 +96,7 @@ pub fn build_app_state() -> AppState { managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), + session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), app_handle: Mutex::new(None), audio_output_device: Mutex::new(None), @@ -116,6 +121,22 @@ impl AppState { self.huddle_state.lock().map_err(|e| e.to_string()) } + pub fn get_session_cache(&self, pubkey: &str) -> Option { + self.session_config_cache.lock().ok()?.get(pubkey).cloned() + } + + pub fn put_session_cache(&self, pubkey: &str, cache: SessionConfigCache) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.insert(pubkey.to_string(), cache); + } + } + + pub fn clear_session_cache(&self, pubkey: &str) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.remove(pubkey); + } + } + /// Emit the current huddle state to the frontend via Tauri event. /// /// Acquires both locks (app_handle + huddle_state), clones a snapshot, diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs new file mode 100644 index 000000000..2c45ab249 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -0,0 +1,656 @@ +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + config_bridge::{ + reader::read_config_surface, + types::{ + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, + ConfigWriteMechanism, NormalizedField, RuntimeConfigSurface, SessionConfigCache, + WriteConfigFieldRequest, WriteConfigResult, WriteConfigTarget, + }, + writer::plan_config_write, + }, + known_acp_runtime, load_managed_agents, load_personas, + resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, + KnownAcpRuntime, ManagedAgentRecord, PersonaRecord, + }, +}; + +/// Resolve the config surface with persona values applied. +/// +/// Both the read path (`get_agent_config_surface`) and the write path +/// (`write_agent_config_field`) must see the same surface, so this is the +/// single place persona resolution happens. The pipeline: resolve the linked +/// persona's prompt/model/provider, inject each into the record only where the +/// record lacks its own value, let `read_config_surface` tag those injected +/// fields `BuzzExplicit`, then re-tag exactly the injected fields to +/// `PersonaDefault`. +/// +/// The re-tag is triple-gated — a field is re-tagged only when (a) the record +/// did not already have it (`!had_*`), (b) the surface produced the field, and +/// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in +/// Buzz keeps `had_* == true` and is never re-tagged. +fn resolve_config_surface( + mut record: ManagedAgentRecord, + personas: &[PersonaRecord], + runtime_meta: Option<&KnownAcpRuntime>, + session_cache: Option<&SessionConfigCache>, +) -> RuntimeConfigSurface { + let had_prompt = + record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); + let had_model = record.model.is_some(); + + let provider_env_key = runtime_meta.and_then(|m| m.provider_env_var).unwrap_or(""); + let had_provider = record.env_vars.contains_key(provider_env_key); + + let (persona_prompt, persona_model, persona_provider) = resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + personas, + record.system_prompt.clone(), + record.model.clone(), + ); + + // Build the baseline the reader overrides a live model against, paired with + // its true origin so the secondary is tagged correctly. Two sources: + // - persona-linked, no explicit record model: the persona model is the + // baseline (PersonaDefault). + // - genuine-explicit (record had its own model) that live-switched: the + // record's own model is the baseline (BuzzExplicit). Gated behind + // `model_overridden` so a persona edited mid-life (override flag false) + // never synthesizes a baseline and false-positives an override. + // An explicit pick with no live switch has no baseline to override. + let model_overridden = session_cache.is_some_and(|c| c.model_overridden); + let baseline = if had_model { + if model_overridden { + record + .model + .clone() + .map(|m| (m, ConfigOrigin::BuzzExplicit)) + } else { + None + } + } else { + persona_model + .clone() + .map(|m| (m, ConfigOrigin::PersonaDefault)) + }; + + // Inject resolved persona values into the record where absent. + if !had_prompt { + if let Some(p) = persona_prompt { + record + .env_vars + .insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p); + } + } + if !had_model { + record.model = persona_model; + } + if !had_provider && !provider_env_key.is_empty() { + if let Some(prov) = persona_provider { + record.env_vars.insert(provider_env_key.to_string(), prov); + } + } + + let mut surface = read_config_surface( + &record, + runtime_meta, + session_cache, + baseline.as_ref().map(|(m, o)| (m.as_str(), o.clone())), + ); + + // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. + if !had_prompt { + retag_persona_default(&mut surface.normalized.system_prompt); + } + if !had_model { + retag_persona_default(&mut surface.normalized.model); + } + if !had_provider && !provider_env_key.is_empty() { + retag_persona_default(&mut surface.normalized.provider); + } + + surface +} + +/// Re-tag a field's origin from `BuzzExplicit` to `PersonaDefault`, leaving any +/// other origin untouched. No-op when the field is absent. +fn retag_persona_default(field: &mut Option) { + if let Some(field) = field { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } +} + +/// Get the full config surface for a managed agent. +/// +/// Returns normalized + advanced config from all available tiers. +/// Pre-spawn agents show config file values with ACP tiers marked as pending. +/// Persona-sourced values are resolved by `resolve_config_surface`. +#[tauri::command] +pub async fn get_agent_config_surface( + pubkey: String, + 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 personas = load_personas(&app).unwrap_or_default(); + let runtime_meta = known_acp_runtime(&record.agent_command); + let session_cache = state.get_session_cache(&pubkey); + + Ok(resolve_config_surface( + record, + &personas, + 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). +/// +/// Uses the same persona-resolved surface as `get_agent_config_surface` so +/// `plan_config_write` sees persona-sourced fields and never returns +/// "field not available" for a value inherited from the linked persona. +#[tauri::command] +pub async fn write_agent_config_field( + request: WriteConfigFieldRequest, + 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 personas = load_personas(&app).unwrap_or_default(); + let runtime_meta = known_acp_runtime(&record.agent_command); + let session_cache = state.get_session_cache(&request.pubkey); + let surface = resolve_config_surface(record, &personas, 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 model_overridden = payload + .get("modelOverridden") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let cache = SessionConfigCache { + config_options, + available_modes, + available_models, + current_model, + model_overridden, + goose_native_config: None, + captured_at: crate::util::now_iso(), + }; + + 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) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::{BackendKind, RespondTo}; + + fn goose_runtime() -> &'static KnownAcpRuntime { + &KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + } + } + + fn agent_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "agent".to_string(), + name: "Agent".to_string(), + persona_id: Some("persona-1".to_string()), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + mcp_toolsets: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } + } + + fn persona_with_model(model: &str) -> PersonaRecord { + PersonaRecord { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: None, + model: Some(model.to_string()), + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + created_at: "".to_string(), + updated_at: "".to_string(), + } + } + + /// A post-spawn session cache whose live model is `current_model` and whose + /// `model_overridden` flag records whether a `SwitchModel` control signal set + /// it (the live-switch signal). + fn session_cache(current_model: &str, model_overridden: bool) -> SessionConfigCache { + SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some(current_model.to_string()), + model_overridden, + goose_native_config: None, + captured_at: "".to_string(), + } + } + + /// The write path must see a persona-inherited model. Without persona + /// resolution `surface.normalized.model` would be `None` and + /// `plan_config_write` would return "field not available for this runtime". + #[test] + fn write_path_sees_persona_sourced_model_field() { + let record = agent_record(); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + + let result = plan_config_write(&surface, &WriteConfigTarget::Model); + assert!(result.success, "write plan failed: {:?}", result.error); + assert!(matches!( + result.mechanism_used, + ConfigWriteMechanism::RespawnWithEnvVar { .. } + )); + } + + /// A model the user set explicitly in Buzz must never be re-tagged to + /// `PersonaDefault`, even when the linked persona also has a model. + #[test] + fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.model = Some("explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + } + + /// Part A — pending-pick: a genuine-explicit pick X with a divergent live + /// model Y but `model_overridden == false` (the live switch is not yet + /// applied — a restart is pending) must keep X as the primary and must NOT + /// surface Y as an override row. The live `acp_model` does not win. This + /// FAILS against a let-live-acp-win variant (one that dropped the + /// `model_overridden` gate), so it is not vacuous. + #[test] + fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", false); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_ne!(model.overridden_value.as_deref(), Some("model-y")); + } + + /// W2 — genuine-explicit live switch: record.model = X, no persona, + /// `model_overridden == true`, live model = Y. The live Y must render as the + /// primary with a `RuntimeOverride` origin and X as the secondary tagged + /// `BuzzExplicit` (its true source — NOT `PersonaDefault`). FAILS against the + /// shipped no-persona early-return, which left X as primary and Y struck. + #[test] + fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("model-x")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); + } + + /// Y==X collision: a genuine-explicit agent live-switches to the SAME value + /// it already had. There is no real divergence, so the field must be a clean + /// single value with NO secondary row. FAILS against a naive `return base` + /// that would leak the `AcpConfigOption` row `build_model_field` populates. + #[test] + fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-x", true); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + assert_eq!(model.overridden_value, None); + assert_eq!(model.overridden_origin, None); + } + + /// Persona parity (regression): a persona-linked agent with no explicit + /// record model that live-switches still renders the persona model as the + /// secondary tagged `PersonaDefault` — the typed-baseline change must NOT + /// regress the persona arm to a different origin. + #[test] + fn persona_linked_live_switch_keeps_persona_default_secondary() { + let record = agent_record(); + let personas = vec![persona_with_model("persona-model")]; + let cache = session_cache("model-y", true); + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 5f34e2e8f..250d3e023 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -8,12 +8,11 @@ use crate::{ find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, - save_managed_agents, spawn_key_refusal, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, + ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -280,21 +279,43 @@ async fn start_local_agent_with_preflight( /// `"private_key_nsec": ""` and launch the agent with no identity — the same /// hazard the local spawn path refuses via `spawn_key_refusal`. fn build_deploy_payload( + app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) -> Result { - if let Some(error) = spawn_key_refusal(record) { - return Err(error); - } - // The record's env_vars is the complete pinned env map (persona env merged - // under agent overrides at create). `merged_user_env` with an empty persona - // map applies the reserved-key / malformed-key / NUL filtering. Re-reading - // persona env live here would leak post-create credential edits into a - // pinned agent — the bug the create-time snapshot exists to prevent. - let merged_env = crate::managed_agents::merged_user_env( - &std::collections::BTreeMap::new(), - &record.env_vars, - ); + // Merge persona env_vars + agent env_vars for provider deploy. Same + // precedence as local spawn: persona first, agent overrides last. Without + // this, provider-backed agents wouldn't receive credentials saved on the + // persona or the agent itself. + let persona_env = + crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; + let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars); + + // Resolve the persona's structured provider/model so the remote provider + // receives the same authoritative values that local spawn derives from + // `runtime_metadata_env_vars`. Without this, remote deploy would rely on + // stale derived env copies in `env_vars` (or have no provider at all for + // imported personas whose derived keys were filtered at import time). + // + // Precedence mirrors local spawn: persona structured model is authoritative + // when present; the agent record's `model` is a fallback for personas that + // don't specify one (or when no persona is linked). + let (effective_model, effective_provider) = if let Some(pid) = record.persona_id.as_deref() { + let personas = load_personas(app).map_err(|e| { + format!( + "failed to load personas while building deploy payload for persona `{pid}`: {e}" + ) + })?; + let persona = personas + .into_iter() + .find(|p| p.id == pid) + .ok_or_else(|| format!("persona `{pid}` not found while building deploy payload"))?; + let model = persona.model.clone().or(record.model.clone()); + let provider = persona.provider; + (model, provider) + } else { + (record.model.clone(), None) + }; Ok(serde_json::json!({ "name": &record.name, @@ -312,8 +333,11 @@ fn build_deploy_payload( "agent_command": &record.agent_command, "agent_args": &record.agent_args, "system_prompt": &record.system_prompt, - "model": &record.model, - "provider": &record.provider, + "model": effective_model, + // Structured provider from the persona record. Providers that don't + // yet read this field will fall back to env_vars or their own default + // — no protocol break. + "provider": effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, @@ -831,7 +855,7 @@ pub async fn create_managed_agent( .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| "agent disappeared".to_string())?; - build_deploy_payload(&state, rec)? + build_deploy_payload(&app, &state, rec)? }; match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { Ok(()) => spawn_error, @@ -959,7 +983,7 @@ pub async fn start_managed_agent( StartTarget::Provider { backend: record.backend.clone(), cached_binary_path: record.provider_binary_path.clone(), - agent_json: build_deploy_payload(&state, record)?, + agent_json: build_deploy_payload(&app, &state, record)?, } }; @@ -1197,6 +1221,7 @@ pub fn stop_managed_agent( } stop_managed_agent_process(&app, record, &mut runtimes)?; } + state.clear_session_cache(&pubkey); save_managed_agents(&app, &records)?; let record = records .iter() @@ -1246,10 +1271,9 @@ pub fn delete_managed_agent( } if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - // For local agents: kills the process. For remote agents: no-op (the frontend - // sends !shutdown via WebSocket before calling delete). Either way, safe. stop_managed_agent_process(&app, record, &mut runtimes)?; } + state.clear_session_cache(&pubkey); let initial_len = records.len(); records.retain(|record| record.pubkey != pubkey); if records.len() == initial_len { diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a8bce1081..99686fee2 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_config; mod agent_discovery; mod agent_models; mod agent_settings; @@ -28,6 +29,7 @@ mod teams; mod workflows; mod workspace; +pub use agent_config::*; pub use agent_discovery::*; pub use agent_models::*; pub use agent_settings::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b713fce13..2da8a7053 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -818,6 +818,9 @@ pub fn run() { delete_managed_agent, get_managed_agent_log, get_agent_models, + get_agent_config_surface, + write_agent_config_field, + put_agent_session_config, mesh_availability, mesh_start_node, mesh_ensure_client_node, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs b/desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs new file mode 100644 index 000000000..e887db8a3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/buzz_agent.rs @@ -0,0 +1,7 @@ +use super::types::RuntimeFileConfig; + +/// Buzz-agent has no config file — returns an empty config. +/// All config comes from env vars (tier 2a) set at spawn time. +pub(super) fn read_config_file() -> Option { + None +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs new file mode 100644 index 000000000..31278fa78 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -0,0 +1,155 @@ +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. +pub(super) fn read_config_file() -> Option { + let home = dirs::home_dir()?; + let settings_path = home.join(".claude").join("settings.json"); + let mcp_path = home.join(".claude.json"); + + let settings = read_json_file(&settings_path); + let mcp_config = read_json_file(&mcp_path); + + if settings.is_none() && mcp_config.is_none() { + return None; + } + + let mut cfg = RuntimeFileConfig::default(); + + if let Some(ref s) = settings { + cfg.model = json_string(s, "model"); + + if let Some(permissions) = s.get("permissions") { + if let Some(mode) = permissions.get("default").and_then(|v| v.as_str()) { + cfg.extra + .insert("permissions.default".to_string(), mode.to_string()); + } + } + + if s.get("hooks").is_some() { + cfg.extra + .insert("hooks".to_string(), "configured".to_string()); + } + + if let Some(style) = json_string(s, "outputStyle") { + cfg.extra.insert("outputStyle".to_string(), style); + } + } + + // MCP servers from ~/.claude.json + let mut extensions = Vec::new(); + if let Some(ref mc) = mcp_config { + if let Some(servers) = mc.get("mcpServers").and_then(|v| v.as_object()) { + for (name, _config) in servers { + extensions.push(ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }); + } + } + } + cfg.extensions = extensions; + + // Provider is always Anthropic for Claude Code. + cfg.extra + .insert("provider_locked".to_string(), "true".to_string()); + + Some(cfg) +} + +fn read_json_file(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn json_string(val: &serde_json::Value, key: &str) -> Option { + val.get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_settings(json: &str) -> RuntimeFileConfig { + use std::collections::BTreeMap; + let val: serde_json::Value = serde_json::from_str(json).unwrap(); + let mut extra = BTreeMap::new(); + if let Some(permissions) = val.get("permissions") { + if let Some(mode) = permissions.get("default").and_then(|v| v.as_str()) { + extra.insert("permissions.default".to_string(), mode.to_string()); + } + } + if val.get("hooks").is_some() { + extra.insert("hooks".to_string(), "configured".to_string()); + } + if let Some(style) = json_string(&val, "outputStyle") { + extra.insert("outputStyle".to_string(), style); + } + RuntimeFileConfig { + model: json_string(&val, "model"), + system_prompt: None, + extra, + ..Default::default() + } + } + + #[test] + fn parse_model_from_settings() { + let cfg = parse_settings(r#"{"model": "claude-sonnet-4-20250514"}"#); + assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4-20250514")); + } + + #[test] + fn parse_permissions_and_hooks() { + let cfg = parse_settings( + r#"{"permissions": {"default": "bypassPermissions"}, "hooks": {"pre-commit": {}}}"#, + ); + assert_eq!( + cfg.extra.get("permissions.default").map(|s| s.as_str()), + Some("bypassPermissions") + ); + assert_eq!( + cfg.extra.get("hooks").map(|s| s.as_str()), + Some("configured") + ); + } + + #[test] + fn parse_output_style_in_extra() { + let cfg = parse_settings(r#"{"outputStyle": "Be concise and technical"}"#); + assert_eq!( + cfg.extra.get("outputStyle").map(|s| s.as_str()), + Some("Be concise and technical") + ); + assert!(cfg.system_prompt.is_none()); + } + + #[test] + fn parse_mcp_servers() { + let json = + r#"{"mcpServers": {"filesystem": {"command": "npx"}, "github": {"command": "gh"}}}"#; + let val: serde_json::Value = serde_json::from_str(json).unwrap(); + let mut extensions = Vec::new(); + if let Some(servers) = val.get("mcpServers").and_then(|v| v.as_object()) { + for (name, _) in servers { + extensions.push(ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }); + } + } + assert_eq!(extensions.len(), 2); + } + + #[test] + fn empty_settings_returns_defaults() { + let cfg = parse_settings("{}"); + assert!(cfg.model.is_none()); + assert!(cfg.system_prompt.is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs new file mode 100644 index 000000000..721f7e8b3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -0,0 +1,170 @@ +use std::collections::BTreeMap; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read Codex config from `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`). +pub(super) fn read_config_file() -> Option { + let path = codex_config_path()?; + let raw = std::fs::read_to_string(path).ok()?; + parse_codex_config(&raw) +} + +fn parse_codex_config(toml_str: &str) -> Option { + let table: toml::Table = toml_str.parse().ok()?; + + let model = toml_string(&table, "model"); + let model_provider = toml_string(&table, "model_provider"); + let approval_policy = toml_string(&table, "approval_policy"); + let sandbox_mode = toml_string(&table, "sandbox_mode"); + let reasoning_effort = toml_string(&table, "model_reasoning_effort"); + let context_window = toml_string(&table, "model_context_window"); + + // Two-axis mode: approval_policy × sandbox_mode + let mode = match (approval_policy.as_deref(), sandbox_mode.as_deref()) { + (Some(ap), Some(sm)) => Some(format!("{ap}/{sm}")), + (Some(ap), None) => Some(ap.to_string()), + (None, Some(sm)) => Some(format!("default/{sm}")), + (None, None) => None, + }; + + let mut extra = BTreeMap::new(); + if let Some(ref ap) = approval_policy { + extra.insert("approval_policy".to_string(), ap.clone()); + } + if let Some(ref sm) = sandbox_mode { + extra.insert("sandbox_mode".to_string(), sm.clone()); + } + + // MCP servers from [mcp_servers.] tables + let extensions = parse_mcp_servers(&table); + + // Custom model providers from [model_providers.] + if let Some(providers) = table.get("model_providers").and_then(|v| v.as_table()) { + for (name, _) in providers { + extra.insert(format!("model_providers.{name}"), "configured".to_string()); + } + } + + Some(RuntimeFileConfig { + model, + provider: model_provider, + mode, + thinking_effort: reasoning_effort, + max_output_tokens: None, + context_limit: context_window, + system_prompt: toml_string(&table, "instructions"), + extensions, + extra, + }) +} + +fn parse_mcp_servers(table: &toml::Table) -> Vec { + let servers = match table.get("mcp_servers").and_then(|v| v.as_table()) { + Some(s) => s, + None => return Vec::new(), + }; + + servers + .iter() + .map(|(name, _config)| ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }) + .collect() +} + +fn toml_string(table: &toml::Table, key: &str) -> Option { + table + .get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn codex_config_path() -> Option { + if let Ok(home) = std::env::var("CODEX_HOME") { + return Some(std::path::PathBuf::from(home).join("config.toml")); + } + let home = dirs::home_dir()?; + Some(home.join(".codex").join("config.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_basic_config() { + let toml = r#" +model = "o3" +model_provider = "openai" +approval_policy = "unless-allow-listed" +sandbox_mode = "permissive" +model_reasoning_effort = "high" +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.model.as_deref(), Some("o3")); + assert_eq!(cfg.provider.as_deref(), Some("openai")); + assert_eq!(cfg.mode.as_deref(), Some("unless-allow-listed/permissive")); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + } + + #[test] + fn parse_mcp_servers() { + let toml = r#" +model = "gpt-4.1" + +[mcp_servers.filesystem] +command = "npx" +args = ["-y", "@anthropic-ai/mcp-filesystem"] + +[mcp_servers.github] +command = "gh" +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.extensions.len(), 2); + } + + #[test] + fn parse_custom_providers() { + let toml = r#" +model = "my-model" +model_provider = "custom-provider" + +[model_providers.custom-provider] +base_url = "http://localhost:8080" +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("custom-provider")); + assert!(cfg.extra.contains_key("model_providers.custom-provider")); + } + + #[test] + fn approval_only_mode() { + let toml = r#"approval_policy = "on-failure""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.mode.as_deref(), Some("on-failure")); + } + + #[test] + fn sandbox_only_mode() { + let toml = r#"sandbox_mode = "strict""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.mode.as_deref(), Some("default/strict")); + } + + #[test] + fn empty_config() { + let cfg = parse_codex_config("").unwrap(); + assert!(cfg.model.is_none()); + assert!(cfg.provider.is_none()); + assert!(cfg.mode.is_none()); + } + + #[test] + fn invalid_toml_returns_none() { + assert!(parse_codex_config("{{{{not valid").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs new file mode 100644 index 000000000..faf1fe79d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -0,0 +1,249 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read goose config from `~/.config/goose/config.yaml` (or `$GOOSE_PATH_ROOT`). +pub(super) fn read_config_file() -> Option { + let path = goose_config_path()?; + read_config_from_path(&path) +} + +fn read_config_from_path(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + parse_goose_config(&raw) +} + +fn parse_goose_config(yaml_str: &str) -> Option { + let map: std::collections::HashMap = + serde_yaml::from_str(yaml_str).ok()?; + + let active_provider = yaml_string(&map, "active_provider"); + + // Flat-key extraction (top-level env-style keys). + let goose_provider = yaml_string(&map, "GOOSE_PROVIDER"); + let goose_model = yaml_string(&map, "GOOSE_MODEL"); + let goose_mode = yaml_string(&map, "GOOSE_MODE"); + let goose_max_tokens = yaml_string(&map, "GOOSE_MAX_TOKENS"); + let goose_context_limit = yaml_string(&map, "GOOSE_CONTEXT_LIMIT"); + + // Nested provider format: active_provider → providers..{model,host,...} + let nested = active_provider + .as_deref() + .and_then(|ap| nested_provider_fields(&map, ap)); + + let provider = goose_provider.or_else(|| active_provider.clone()); + let model = goose_model.or_else(|| nested.as_ref().and_then(|n| n.model.clone())); + let mode = goose_mode; + + let extensions = parse_extensions(&map); + + let mut extra = BTreeMap::new(); + if let Some(ref ap) = active_provider { + extra.insert("active_provider".to_string(), ap.clone()); + } + if let Some(host) = yaml_string(&map, "DATABRICKS_HOST") + .or_else(|| nested.as_ref().and_then(|n| n.host.clone())) + { + let host_key = match active_provider.as_deref() { + Some("databricks_v2") | Some("databricks") => "DATABRICKS_HOST".to_string(), + Some(p) => format!("{p}.host"), + None => "provider.host".to_string(), + }; + extra.insert(host_key, host); + } + + Some(RuntimeFileConfig { + model, + provider, + mode, + thinking_effort: yaml_string(&map, "GOOSE_THINKING_EFFORT"), + max_output_tokens: goose_max_tokens, + context_limit: goose_context_limit, + system_prompt: None, + extensions, + extra, + }) +} + +struct NestedProviderFields { + model: Option, + host: Option, +} + +fn nested_provider_fields( + map: &std::collections::HashMap, + active_provider: &str, +) -> Option { + let providers = map.get("providers").and_then(|v| v.as_mapping())?; + let entry = providers + .get(serde_yaml::Value::String(active_provider.to_owned()))? + .as_mapping()?; + + let model = mapping_string(entry, "model"); + let host = mapping_string(entry, "host"); + + Some(NestedProviderFields { model, host }) +} + +fn parse_extensions( + map: &std::collections::HashMap, +) -> Vec { + let extensions = match map.get("extensions").and_then(|v| v.as_mapping()) { + Some(m) => m, + None => return Vec::new(), + }; + + extensions + .iter() + .filter_map(|(k, v)| { + let name = k.as_str()?.to_string(); + let kind = v + .as_mapping() + .and_then(|m| mapping_string(m, "type")) + .unwrap_or_else(|| "unknown".to_string()); + let enabled = v + .as_mapping() + .and_then(|m| { + m.get(serde_yaml::Value::String("enabled".to_owned())) + .and_then(|v| v.as_bool()) + }) + .unwrap_or(true); + Some(ExtensionEntry { + name, + kind, + enabled, + }) + }) + .collect() +} + +fn yaml_string( + map: &std::collections::HashMap, + key: &str, +) -> Option { + map.get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_owned())) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn goose_config_path() -> Option { + if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { + return Some(PathBuf::from(root).join("config").join("config.yaml")); + } + let home = dirs::home_dir()?; + Some(home.join(".config").join("goose").join("config.yaml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_flat_keys() { + let yaml = r#" +GOOSE_PROVIDER: anthropic +GOOSE_MODEL: claude-sonnet-4-20250514 +GOOSE_MODE: auto +GOOSE_MAX_TOKENS: "8192" +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4-20250514")); + assert_eq!(cfg.mode.as_deref(), Some("auto")); + assert_eq!(cfg.max_output_tokens.as_deref(), Some("8192")); + } + + #[test] + fn parse_nested_provider() { + let yaml = r#" +active_provider: databricks_v2 +providers: + databricks_v2: + model: goose-claude-4-6-opus + host: https://dbc.example +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("databricks_v2")); + assert_eq!(cfg.model.as_deref(), Some("goose-claude-4-6-opus")); + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://dbc.example") + ); + } + + #[test] + fn non_databricks_provider_uses_provider_host_key() { + let yaml = r#" +active_provider: anthropic +providers: + anthropic: + model: claude-opus-4 + host: https://api.anthropic.com +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("anthropic")); + assert_eq!( + cfg.extra.get("anthropic.host").map(|s| s.as_str()), + Some("https://api.anthropic.com") + ); + assert!(!cfg.extra.contains_key("DATABRICKS_HOST")); + } + + #[test] + fn flat_model_wins_over_nested() { + let yaml = r#" +active_provider: databricks_v2 +GOOSE_MODEL: flat-model +providers: + databricks_v2: + model: nested-model +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.model.as_deref(), Some("flat-model")); + } + + #[test] + fn parse_extensions() { + let yaml = r#" +extensions: + developer: + type: builtin + enabled: true + my-mcp: + type: stdio + enabled: false +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.extensions.len(), 2); + assert!(cfg + .extensions + .iter() + .any(|e| e.name == "developer" && e.enabled)); + assert!(cfg + .extensions + .iter() + .any(|e| e.name == "my-mcp" && !e.enabled)); + } + + #[test] + fn invalid_yaml_returns_none() { + assert!(parse_goose_config("{{{{not valid").is_none()); + } + + #[test] + fn empty_yaml_returns_empty_config() { + let cfg = parse_goose_config("{}").unwrap(); + assert!(cfg.model.is_none()); + assert!(cfg.provider.is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs new file mode 100644 index 000000000..dd42f0463 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -0,0 +1,9 @@ +mod buzz_agent; +mod claude; +mod codex; +mod goose; +pub(crate) mod reader; +pub(crate) mod types; +pub(crate) mod writer; + +pub(crate) use types::*; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs new file mode 100644 index 000000000..c6d401710 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -0,0 +1,883 @@ +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>, + baseline: Option<(&str, ConfigOrigin)>, +) -> 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 model_overridden = session_cache.is_some_and(|c| c.model_overridden); + + let normalized = NormalizedConfig { + model: Some(apply_runtime_override( + build_model_field( + &record_model, + &file_config.model, + &acp_model, + model_env_var, + supports_acp_model, + is_pre_spawn, + session_cache, + ), + acp_model.as_deref(), + baseline, + model_overridden, + )), + 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) + }; + + // The secondary expresses ONLY the static record-vs-file precedence: a + // Buzz-explicit model shadowing a config-file model. The live-session + // override (acp vs record/persona) is exclusively `apply_runtime_override`'s + // job, gated on `model_overridden`. Surfacing `acp_model` here would leak an + // override row even when no live switch has been applied. + let (overridden_value, overridden_origin) = if record_model.is_some() && file_model.is_some() { + (file_model.clone(), Some(ConfigOrigin::ConfigFile)) + } else { + (None, None) + }; + + let write_via = model_write_mechanism( + is_pre_spawn, + supports_acp_model, + session_cache, + model_env_var, + ); + + NormalizedField { + value, + origin, + is_writable: !matches!(write_via, ConfigWriteMechanism::ReadOnly), + write_via, + overridden_value, + overridden_origin, + } +} + +/// Resolve how the model field is written back to the runtime. +/// Prefer ACP `set_config_option`/`set_model` post-spawn, else env-var respawn. +fn model_write_mechanism( + is_pre_spawn: bool, + supports_acp_model: bool, + session_cache: Option<&SessionConfigCache>, + model_env_var: Option<&str>, +) -> ConfigWriteMechanism { + if !is_pre_spawn && has_config_option(session_cache, "model") { + let config_id = find_model_config_id(session_cache).unwrap_or_else(|| "model".to_string()); + ConfigWriteMechanism::AcpSetConfigOption { config_id } + } else if !is_pre_spawn && supports_acp_model { + ConfigWriteMechanism::AcpSetSessionModel + } else if let Some(env_key) = model_env_var { + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: env_key.to_string(), + } + } else { + ConfigWriteMechanism::ReadOnly + } +} + +/// Re-key the model field as a live runtime override when the harness signals +/// that a `SwitchModel` control signal set the model (Phase 3c). +/// +/// The override-active signal is `model_overridden` from the +/// `session_config_captured` payload — NOT `acp_model != persona_model`, which +/// would false-positive when a persona model is edited mid-life while the +/// session is stale on the old model. +/// +/// `baseline` is the value the live model overrides, paired with its true +/// origin — `(persona_model, PersonaDefault)` for a persona-linked agent, or +/// `(record_model, BuzzExplicit)` for a genuine-explicit agent that live- +/// switched. It is `Some` only when there is such a baseline to override +/// against; otherwise the field passes through unchanged. Carrying the origin +/// in the pair (rather than hardcoding it) lets the secondary be tagged by its +/// real source instead of always reading `PersonaDefault`. +/// +/// The `acp == baseline_value` short-circuit keeps a live pick of the baseline +/// model itself from rendering a no-op "override of X with X". It yields a +/// CLEAN single-value field — `overridden_value`/`overridden_origin` cleared — +/// rather than passing `base` through, because `build_model_field` already +/// populates `base`'s secondary with an `AcpConfigOption` row for the +/// record-model-plus-live-session case; returning `base` would leak that +/// spurious row. The override preserves the base field's write mechanism — only +/// the displayed value, origin, and secondary change. +fn apply_runtime_override( + base: NormalizedField, + acp_model: Option<&str>, + baseline: Option<(&str, ConfigOrigin)>, + model_overridden: bool, +) -> NormalizedField { + if !model_overridden { + return base; + } + let (Some(acp), Some((baseline_value, baseline_origin))) = (acp_model, baseline) else { + return base; + }; + if acp == baseline_value { + // Live pick equals the baseline — no real divergence. Strip any + // secondary `build_model_field` may have produced so the panel shows a + // single clean value rather than "X overridden by X". + return NormalizedField { + overridden_value: None, + overridden_origin: None, + ..base + }; + } + NormalizedField { + value: Some(acp.to_string()), + origin: ConfigOrigin::RuntimeOverride, + overridden_value: Some(baseline_value.to_string()), + overridden_origin: Some(baseline_origin), + ..base + } +} + +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, + agent_command_override: None, + persona_source_version: None, + provider: 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, 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, 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, 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()), + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); + assert!(!surface.is_pre_spawn); + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("claude-opus-4")); + 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()), + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("acp-model")); + assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); + // 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. + } + + // ── Persona resolution integration tests ──────────────────────────── + // + // These simulate the call-site pattern in agent_config.rs: + // 1. Inject persona-resolved values into the record (as if absent) + // 2. Call read_config_surface (reader tags them BuzzExplicit) + // 3. Re-tag injected fields to PersonaDefault + // + // This exercises the same logic path as get_agent_config_surface without + // requiring Tauri AppHandle/State infrastructure. + + #[test] + fn persona_model_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no model, persona provides one. + // The call-site injects it before calling the reader. + record.model = Some("persona-model".to_string()); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None, None); + + // Reader sees injected model as BuzzExplicit. + let model = surface.normalized.model.as_ref().unwrap(); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_model == false). + if let Some(ref mut field) = surface.normalized.model { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + } + + // ── Runtime override (Phase 3c) ────────────────────────────────────── + // + // A live ModelPicker switch is signalled by `model_overridden: true` in the + // `session_config_captured` payload. The reader keys the override-active + // decision off that flag — NOT off `acp_model != persona_model`, which would + // false-positive when a persona model is edited mid-life. + + #[test] + fn runtime_override_wins_display_when_model_overridden_is_true() { + // Persona-linked agent (record.model == None); persona == "persona-model". + // A live switch pushed "live-model" to the session and set model_overridden. + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("live-model".to_string()), + model_overridden: true, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface( + &record, + Some(runtime), + Some(&cache), + Some(("persona-model", ConfigOrigin::PersonaDefault)), + ); + let model = surface.normalized.model.unwrap(); + + // Override wins the display value with a runtime-override origin. + assert_eq!(model.value.as_deref(), Some("live-model")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + // Persona is the secondary value (not struck through — the UI keys off + // the RuntimeOverride origin to suppress strikethrough). + assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } + + #[test] + fn no_runtime_override_when_model_overridden_is_false() { + // At spawn the session's current_model == persona model (BUZZ_ACP_MODEL + // is set to the persona model) and model_overridden is false. No override; + // the field falls through to normal precedence. + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("persona-model".to_string()), + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface( + &record, + Some(runtime), + Some(&cache), + Some(("persona-model", ConfigOrigin::PersonaDefault)), + ); + let model = surface.normalized.model.unwrap(); + + // model_overridden is false => the override branch is not taken: origin + // is the normal precedence result, never RuntimeOverride. + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } + + #[test] + fn no_false_positive_override_when_persona_edited_mid_life() { + // Persona-linked agent whose persona model was edited A→B while the + // session is stale on the old model A. `model_overridden` is false + // because no SwitchModel control signal was sent — the session is merely + // stale. Despite acp_model("A") != persona_model("B"), no RuntimeOverride + // should be displayed. + let record = test_record(); + let runtime = test_runtime(); + let cache = SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some("old-persona-model".to_string()), + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + + let surface = read_config_surface( + &record, + Some(runtime), + Some(&cache), + Some(("new-persona-model", ConfigOrigin::PersonaDefault)), + ); + let model = surface.normalized.model.unwrap(); + + // model_overridden is false => no RuntimeOverride, even though + // acp_model != persona_model. The old divergence-based signal would + // have false-positived here. The persona is never surfaced as the + // overridden secondary (that marker is exclusive to a real override). + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); + } + + #[test] + fn persona_provider_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no provider env var, persona provides one. + // The call-site injects it as GOOSE_PROVIDER before calling the reader. + record + .env_vars + .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None, None); + + // Reader sees injected provider as BuzzExplicit. + let provider = surface.normalized.provider.as_ref().unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_provider == false). + if let Some(ref mut field) = surface.normalized.provider { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let provider = surface.normalized.provider.unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn persona_system_prompt_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no system_prompt, persona provides one via env var. + // The call-site injects it as BUZZ_ACP_SYSTEM_PROMPT before calling the reader. + record.env_vars.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "You are a helpful assistant.".to_string(), + ); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None, None); + + // Reader sees injected prompt as BuzzExplicit. + let prompt = surface.normalized.system_prompt.as_ref().unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_prompt == false). + if let Some(ref mut field) = surface.normalized.system_prompt { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn explicit_record_model_not_retagged_when_already_present() { + let mut record = test_record(); + // Record already has its own model — persona resolution should NOT re-tag. + record.model = Some("explicit-model".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, None); + + // had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit. + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs new file mode 100644 index 000000000..ae6db7dcb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -0,0 +1,229 @@ +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. + /// Populated by the `get_agent_config_surface` call site: persona values are + /// resolved before calling the reader, then the surface is post-processed to + /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. + PersonaDefault, + /// Live runtime model override applied via the ModelPicker (Phase 3). + /// The ACP session's current model diverges from the persona model because + /// the user picked a different model on the running instance. Runtime-only — + /// never persisted; reverts to the persona model on restart/respawn. + RuntimeOverride, +} + +/// How a config field can be written back to the runtime. +#[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, + /// Whether the harness's `desired_model` was set by a live `SwitchModel` + /// control signal (true) vs derived from config/persona at spawn (false). + /// Used by the reader to distinguish a genuine runtime override from a + /// stale session whose persona model was edited mid-life. + #[serde(default)] + pub model_overridden: bool, + pub goose_native_config: Option, + pub captured_at: String, +} + +/// A single ACP configOption from session/new. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigOptionEntry { + pub config_id: String, + pub category: Option, + pub display_name: Option, + pub current_value: Option, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigOptionValue { + pub value: String, + pub display_name: Option, +} + +/// A model entry from ACP session/new. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpModelEntry { + pub model_id: String, + pub name: Option, + pub description: Option, +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/writer.rs b/desktop/src-tauri/src/managed_agents/config_bridge/writer.rs new file mode 100644 index 000000000..51307935c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/writer.rs @@ -0,0 +1,146 @@ +use super::types::*; + +/// Route a config write to the correct mechanism and return the result. +/// +/// This does NOT execute the write — it determines what mechanism should be +/// used and returns the `WriteConfigResult` describing the action. The caller +/// (Tauri command) is responsible for executing the actual write (updating +/// the record and restarting, or sending an observer control event). +pub(crate) fn plan_config_write( + surface: &RuntimeConfigSurface, + target: &WriteConfigTarget, +) -> WriteConfigResult { + let field = match target { + WriteConfigTarget::Model => surface.normalized.model.as_ref(), + WriteConfigTarget::Provider => surface.normalized.provider.as_ref(), + WriteConfigTarget::Mode => surface.normalized.mode.as_ref(), + WriteConfigTarget::ThinkingEffort => surface.normalized.thinking_effort.as_ref(), + WriteConfigTarget::MaxOutputTokens => surface.normalized.max_output_tokens.as_ref(), + WriteConfigTarget::ContextLimit => surface.normalized.context_limit.as_ref(), + WriteConfigTarget::SystemPrompt => surface.normalized.system_prompt.as_ref(), + WriteConfigTarget::Advanced { key } => { + let adv = surface.advanced.iter().find(|f| f.key == *key); + return match adv { + Some(f) if f.is_writable => WriteConfigResult { + success: true, + mechanism_used: f.write_via.clone(), + requires_restart: matches!( + f.write_via, + ConfigWriteMechanism::RespawnWithEnvVar { .. } + ), + error: None, + }, + Some(_) => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some(format!("field '{key}' is read-only")), + }, + None => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some(format!("unknown advanced field '{key}'")), + }, + }; + } + }; + + match field { + Some(f) if f.is_writable => WriteConfigResult { + success: true, + mechanism_used: f.write_via.clone(), + requires_restart: matches!(f.write_via, ConfigWriteMechanism::RespawnWithEnvVar { .. }), + error: None, + }, + Some(_) => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some("field is read-only".to_string()), + }, + None => WriteConfigResult { + success: false, + mechanism_used: ConfigWriteMechanism::ReadOnly, + requires_restart: false, + error: Some("field not available for this runtime".to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn surface_with_writable_model() -> RuntimeConfigSurface { + RuntimeConfigSurface { + runtime_id: Some("goose".to_string()), + runtime_label: Some("Goose".to_string()), + is_pre_spawn: false, + normalized: NormalizedConfig { + model: Some(NormalizedField { + value: Some("claude-opus-4".to_string()), + origin: ConfigOrigin::BuzzExplicit, + is_writable: true, + write_via: ConfigWriteMechanism::AcpSetConfigOption { + config_id: "model".to_string(), + }, + overridden_value: None, + overridden_origin: None, + }), + provider: None, + mode: None, + thinking_effort: None, + max_output_tokens: None, + context_limit: None, + system_prompt: None, + }, + advanced: vec![], + sources: ConfigSourceReport { + acp_native: ConfigTierStatus::NotApplicable, + acp_config_options: ConfigTierStatus::Available, + env_vars: ConfigTierStatus::Available, + config_file: ConfigTierStatus::NotApplicable, + config_file_path: None, + }, + } + } + + #[test] + fn writable_model_returns_acp_mechanism() { + let surface = surface_with_writable_model(); + let result = plan_config_write(&surface, &WriteConfigTarget::Model); + assert!(result.success); + assert!(!result.requires_restart); + assert!(matches!( + result.mechanism_used, + ConfigWriteMechanism::AcpSetConfigOption { .. } + )); + } + + #[test] + fn missing_field_returns_error() { + let surface = surface_with_writable_model(); + let result = plan_config_write(&surface, &WriteConfigTarget::Mode); + assert!(!result.success); + assert!(result.error.is_some()); + } + + #[test] + fn respawn_mechanism_requires_restart() { + let mut surface = surface_with_writable_model(); + surface.normalized.model = Some(NormalizedField { + value: Some("my-model".to_string()), + origin: ConfigOrigin::EnvVar, + is_writable: true, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODEL".to_string(), + }, + overridden_value: None, + overridden_origin: None, + }); + let result = plan_config_write(&surface, &WriteConfigTarget::Model); + assert!(result.success); + assert!(result.requires_restart); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2e3518b50..5830d062e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -42,6 +42,11 @@ pub(crate) struct KnownAcpRuntime { pub provider_env_var: Option<&'static str>, pub provider_locked: bool, pub default_env: &'static [(&'static str, &'static str)], + pub config_file_path: Option<&'static str>, + #[allow(dead_code)] // reserved for format-based dispatch when readers are unified + pub config_file_format: Option<&'static str>, + pub supports_acp_native_config: bool, // tier 1a: config/read+write + pub thinking_env_var: Option<&'static str>, } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -93,6 +98,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ provider_env_var: Some("GOOSE_PROVIDER"), provider_locked: false, default_env: &[("GOOSE_MODE", "auto")], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), }, KnownAcpRuntime { id: "claude", @@ -114,6 +123,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ provider_env_var: None, provider_locked: true, default_env: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, }, KnownAcpRuntime { id: "codex", @@ -133,8 +146,12 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ supports_acp_model_switching: false, model_env_var: None, provider_env_var: None, - provider_locked: true, + provider_locked: false, default_env: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, }, KnownAcpRuntime { id: "buzz-agent", @@ -156,6 +173,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ provider_env_var: Some("BUZZ_AGENT_PROVIDER"), provider_locked: false, default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: None, }, ]; diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index f4f55c206..3d928dbfb 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -429,7 +429,7 @@ fn is_derived_key_matches_all_known_keys() { for key in DERIVED_PROVIDER_MODEL_ENV_KEYS { assert!( is_derived_provider_model_key(key), - "{key} should be recognized as derived" + "expected `{key}` to be recognized as derived" ); } } @@ -455,25 +455,35 @@ fn is_derived_key_does_not_match_unrelated_keys() { #[test] fn filter_derived_strips_provider_model_keys_preserves_rest() { let input = vec![ - ( - "GOOSE_MODEL".to_string(), - "claude-sonnet-4-20250514".to_string(), - ), - ("GOOSE_PROVIDER".to_string(), "anthropic".to_string()), + ("GOOSE_MODEL".to_string(), "old-model".to_string()), + ("GOOSE_PROVIDER".to_string(), "old-provider".to_string()), ("BUZZ_AGENT_MODEL".to_string(), "gpt-4o".to_string()), ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), ("GOOSE_TEMPERATURE".to_string(), "0.7".to_string()), - ("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string()), + ("GOOSE_CONTEXT_LIMIT".to_string(), "128000".to_string()), + ("CUSTOM_KEY".to_string(), "custom-value".to_string()), ]; + let filtered = filter_derived_provider_model_env_vars(input); - assert_eq!(filtered.len(), 2); + + // Derived keys must be gone. + assert!(!filtered.contains_key("GOOSE_MODEL")); + assert!(!filtered.contains_key("GOOSE_PROVIDER")); + assert!(!filtered.contains_key("BUZZ_AGENT_MODEL")); + assert!(!filtered.contains_key("BUZZ_AGENT_PROVIDER")); + + // Non-derived keys must survive. assert_eq!( filtered.get("GOOSE_TEMPERATURE").map(String::as_str), Some("0.7") ); assert_eq!( - filtered.get("ANTHROPIC_API_KEY").map(String::as_str), - Some("sk-test") + filtered.get("GOOSE_CONTEXT_LIMIT").map(String::as_str), + Some("128000") + ); + assert_eq!( + filtered.get("CUSTOM_KEY").map(String::as_str), + Some("custom-value") ); } @@ -485,19 +495,76 @@ fn filter_derived_empty_input_returns_empty() { #[test] fn stale_derived_env_does_not_override_structured_fields() { - // Documents that merged_user_env is transparent to derived keys — it - // does NOT strip them. The defense is the import filter - // (filter_derived_provider_model_env_vars) which prevents them from - // being persisted in the first place. If a stale record somehow has - // them, they flow through merged_user_env unchanged — the spawn-time - // re-derivation from structured fields writes AFTER merged env. - let persona_env = map(&[("GOOSE_MODEL", "stale-model"), ("LEGIT", "v")]); - let merged = merged_user_env(&persona_env, &BTreeMap::new()); - // merged_user_env does NOT filter derived keys — that's by design. - // The import filter is the boundary defense. + // Scenario: A persona was imported WITH stale derived keys (pre-fix). + // At merge time, `merged_user_env` passes them through (it doesn't filter). + // The fix is at *import* time — this test documents that merged_user_env + // is transparent, and the import filter is the correct defense. + let stale_persona_env = map(&[ + ("BUZZ_AGENT_MODEL", "stale-model"), + ("BUZZ_AGENT_PROVIDER", "stale-provider"), + ("GOOSE_TEMPERATURE", "0.5"), + ]); + let agent_env = BTreeMap::new(); + + let merged = merged_user_env(&stale_persona_env, &agent_env); + + // merged_user_env is transparent — stale keys pass through. assert_eq!( - merged.get("GOOSE_MODEL").map(String::as_str), + merged.get("BUZZ_AGENT_MODEL").map(String::as_str), Some("stale-model") ); - assert_eq!(merged.get("LEGIT").map(String::as_str), Some("v")); + assert_eq!( + merged.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("stale-provider") + ); + + // But the import filter WOULD have caught them: + let would_be_filtered = filter_derived_provider_model_env_vars(stale_persona_env); + assert!(!would_be_filtered.contains_key("BUZZ_AGENT_MODEL")); + assert!(!would_be_filtered.contains_key("BUZZ_AGENT_PROVIDER")); + // Non-derived keys survive the filter. + assert_eq!( + would_be_filtered + .get("GOOSE_TEMPERATURE") + .map(String::as_str), + Some("0.5") + ); +} + +// ── deploy payload model precedence ──────────────────────────────── + +/// Documents the model precedence rule used by `build_deploy_payload`: +/// persona structured model is authoritative when present; the agent +/// record's `model` field is only a fallback. +/// +/// This mirrors local spawn behavior where `runtime_metadata_env_vars` +/// derives GOOSE_MODEL from the persona's structured field, not the +/// agent record. +#[test] +fn deploy_model_precedence_persona_wins_over_record() { + // Simulates the precedence logic from build_deploy_payload: + // let model = persona.model.clone().or(record.model.clone()); + let persona_model: Option = Some("claude-sonnet-4-20250514".to_string()); + let record_model: Option = Some("stale-record-model".to_string()); + + let effective = persona_model.clone().or(record_model.clone()); + assert_eq!(effective.as_deref(), Some("claude-sonnet-4-20250514")); +} + +#[test] +fn deploy_model_precedence_falls_back_to_record_when_persona_has_none() { + let persona_model: Option = None; + let record_model: Option = Some("record-model".to_string()); + + let effective = persona_model.clone().or(record_model.clone()); + assert_eq!(effective.as_deref(), Some("record-model")); +} + +#[test] +fn deploy_model_precedence_none_when_both_absent() { + let persona_model: Option = None; + let record_model: Option = None; + + let effective = persona_model.clone().or(record_model.clone()); + assert_eq!(effective, None); } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a3f4b447b..f6f9a9e95 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod agent_events; mod backend; +pub(crate) mod config_bridge; mod discovery; mod env_vars; mod nest; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ed4680978..48c8a6ce4 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -13,6 +13,7 @@ import { discoverAcpRuntimes, discoverBackendProviders, discoverManagedAgentPrereqs, + getAgentConfigSurface, getManagedAgentLog, installAcpRuntime, listManagedAgents, @@ -553,6 +554,19 @@ export function useManagedAgentLogQuery( }); } +export const agentConfigSurfaceQueryKey = (pubkey: string) => + ["agent-config-surface", pubkey] as const; + +export function useAgentConfigSurface(pubkey: string | null) { + return useQuery({ + queryKey: agentConfigSurfaceQueryKey(pubkey ?? ""), + queryFn: () => getAgentConfigSurface(pubkey ?? ""), + enabled: !!pubkey, + staleTime: 10_000, + refetchInterval: 30_000, + }); +} + export function useTeamsQuery() { return useQuery({ queryKey: teamsQueryKey, diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs new file mode 100644 index 000000000..737d84b86 --- /dev/null +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts"; + +const MODEL = "goose-claude-fable-5"; + +function frame(status, overrides = {}) { + return { type: "switch_model", status, modelId: MODEL, ...overrides }; +} + +/** + * A controllable test harness mirroring the real wiring: a single-listener + * pub/sub whose unsubscribe genuinely detaches (so post-unsubscribe pushes are + * no-ops, matching `observerRelayStore`), a manual timeout, and a deferred + * `sendSwitches` the test resolves explicitly. + */ +function harness(channelCount) { + let listener = null; + let timeoutCb = null; + let unsubscribeCalls = 0; + let cancelTimeoutCalls = 0; + let sendResolve; + const sendStarted = new Promise((resolve) => { + sendResolve = resolve; + }); + + const outcome = awaitLiveSwitchOutcome({ + channelCount, + modelId: MODEL, + subscribe: (fn) => { + listener = fn; + return () => { + unsubscribeCalls += 1; + listener = null; + }; + }, + sendSwitches: () => { + sendResolve(); + return Promise.resolve(); + }, + scheduleTimeout: (cb) => { + timeoutCb = cb; + return () => { + cancelTimeoutCalls += 1; + }; + }, + }); + + return { + outcome, + sendStarted, + push: (f) => listener?.(f), + fireTimeout: () => timeoutCb?.(), + get unsubscribeCalls() { + return unsubscribeCalls; + }, + get cancelTimeoutCalls() { + return cancelTimeoutCalls; + }, + }; +} + +test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => { + const h = harness(2); + // Channel A acks fast as `sent`; a first-ack-resolves impl would settle "ok" + // here. The fail-fast contract must keep waiting and then reject on B. + h.push(frame("sent")); + h.push(frame("unsupported_model")); + assert.equal(await h.outcome, "unsupported"); +}); + +test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", async () => { + const h = harness(3); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + // The `.then` that flips `settled` flushes on a later microtask tick than a + // single drain, so a single `await Promise.resolve()` would let this + // assertion pass even against a first-ack-resolves bug. Draining several + // ticks guarantees a resolved promise's callback has run, so the interim + // `settled === false` checks deterministically regress an early resolve. + const drainMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + }; + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "must not resolve on the first ack"); + + h.push(frame("switched")); + await drainMicrotasks(); + assert.equal(settled, false, "must not resolve before the last ack"); + + h.push(frame("turn_ending")); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes exactly once", async () => { + const h = harness(2); + h.push(frame("unsupported_model")); + assert.equal(await h.outcome, "unsupported"); + assert.equal(h.unsubscribeCalls, 1); + assert.equal(h.cancelTimeoutCalls, 1); + + // A second rejection arriving after the first must not re-resolve or + // re-unsubscribe — the listener is already detached. + h.push(frame("unsupported_model")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + +test("awaitLiveSwitchOutcome ignores frames for a different model or control type", async () => { + const h = harness(1); + h.push(frame("sent", { modelId: "some-other-model" })); + h.push({ type: "cancel_turn", status: "sent", modelId: MODEL }); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + await Promise.resolve(); + assert.equal(settled, false, "unrelated frames must not advance the count"); + + h.push(frame("switched")); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome resolves ok via the timeout fallback when the harness never replies", async () => { + const h = harness(2); + h.fireTimeout(); + assert.equal(await h.outcome, "ok"); + assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes"); +}); + +test("awaitLiveSwitchOutcome fires the per-channel sends after subscribing", async () => { + const h = harness(1); + // The subscription is registered before the sends fire, so a frame arriving + // mid-send is never dropped. Awaiting sendStarted proves sends ran. + await h.sendStarted; + h.push(frame("sent")); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome with zero channels resolves ok at the timeout (no acks expected)", async () => { + // No active turns means channelCount 0: remaining starts at 0 but the success + // resolve only fires inside a frame callback, so with no frames the timeout + // fallback is what settles it. This documents the degenerate path. + const h = harness(0); + h.fireTimeout(); + assert.equal(await h.outcome, "ok"); +}); diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts new file mode 100644 index 000000000..d12261e59 --- /dev/null +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -0,0 +1,66 @@ +import type { ControlResultFrame } from "@/shared/api/types"; + +/** + * Resolve the outcome of a live `switch_model` across one or more channels. + * + * A live switch fires a `switch_model` frame per active channel and learns each + * channel's result asynchronously over the observer relay. The fail-fast rule: + * any single `unsupported_model` result rejects the whole pick immediately; + * every other status must arrive from every channel before resolving success. + * If the harness never replies, the fallback timeout resolves `"ok"` — the + * override still rides the requeued/next session, we just can't confirm it + * synchronously. + * + * The counting lives here, isolated from React and the relay so it can be unit + * tested with synthetic frames and a fake clock. The caller injects the + * relay subscription, the per-channel sends, and the timeout scheduler. + */ +export async function awaitLiveSwitchOutcome({ + channelCount, + modelId, + subscribe, + sendSwitches, + scheduleTimeout, +}: { + /** Number of channels the switch was fired to — the success threshold. */ + channelCount: number; + /** Model being switched to; frames for any other model are ignored. */ + modelId: string; + /** Register a control-result listener; returns an unsubscribe function. */ + subscribe: (listener: (frame: ControlResultFrame) => void) => () => void; + /** Fire the per-channel `switch_model` sends. Resolves when all are sent. */ + sendSwitches: () => Promise; + /** Schedule the no-reply fallback; returns a cancel function. */ + scheduleTimeout: (onTimeout: () => void) => () => void; +}): Promise<"ok" | "unsupported"> { + const settled = new Promise<"ok" | "unsupported">((resolve) => { + let unsubscribe = () => {}; + let cancelTimeout = () => {}; + let remaining = channelCount; + const finish = (outcome: "ok" | "unsupported") => { + cancelTimeout(); + unsubscribe(); + resolve(outcome); + }; + cancelTimeout = scheduleTimeout(() => finish("ok")); + unsubscribe = subscribe((frame) => { + if (frame.type !== "switch_model" || frame.modelId !== modelId) { + return; + } + if (frame.status === "unsupported_model") { + // Any single failure rejects the whole pick immediately. + finish("unsupported"); + return; + } + // sent / switched / turn_ending — count as success for this channel. + remaining -= 1; + if (remaining <= 0) { + finish("ok"); + } + }); + }); + + await sendSwitches(); + + return settled; +} diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6f3370de0..5aec6561c 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -2,7 +2,8 @@ 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 type { ControlResultFrame } from "@/shared/api/types"; +import { getIdentity, putAgentSessionConfig } from "@/shared/api/tauri"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { @@ -38,6 +39,14 @@ const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +// Per-agent listeners for `control_result` frames. The ModelPicker subscribes +// here to learn the async outcome of a `switch_model` frame (the send is +// fire-and-forget; the harness replies out-of-band over the observer relay). +const controlResultListeners = new Map< + string, + Set<(frame: ControlResultFrame) => void> +>(); + // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). // @@ -192,6 +201,11 @@ async function handleRelayObserverEvent( return; } appendAgentEvent(agentPubkey, parsed); + if (parsed.kind === "session_config_captured") { + void putAgentSessionConfig(agentPubkey, parsed.payload); + } else if (parsed.kind === "control_result") { + dispatchControlResult(agentPubkey, parsed.payload); + } } catch (error) { if (activeGeneration !== generation) { return; @@ -268,6 +282,53 @@ export function subscribeAgentObserverStore(listener: () => void) { }; } +function isControlResultFrame(payload: unknown): payload is ControlResultFrame { + return ( + typeof payload === "object" && + payload !== null && + typeof (payload as { type?: unknown }).type === "string" && + typeof (payload as { status?: unknown }).status === "string" + ); +} + +function dispatchControlResult(agentPubkey: string, payload: unknown) { + if (!isControlResultFrame(payload)) { + return; + } + const subscribers = controlResultListeners.get(normalizePubkey(agentPubkey)); + if (!subscribers) { + return; + } + for (const subscriber of subscribers) { + subscriber(payload); + } +} + +/** + * Subscribe to `control_result` frames for a single agent. Returns an + * unsubscribe function. Used by the ModelPicker to learn the async outcome of + * a `switch_model` frame. + */ +export function subscribeControlResults( + agentPubkey: string, + listener: (frame: ControlResultFrame) => void, +) { + const key = normalizePubkey(agentPubkey); + const subscribers = controlResultListeners.get(key) ?? new Set(); + subscribers.add(listener); + controlResultListeners.set(key, subscribers); + return () => { + const current = controlResultListeners.get(key); + if (!current) { + return; + } + current.delete(listener); + if (current.size === 0) { + controlResultListeners.delete(key); + } + }; +} + export function getAgentObserverSnapshot( agentPubkey?: string | null, enabled?: boolean, diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx new file mode 100644 index 000000000..8c61a6dcf --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +import { useAgentConfigSurface } from "../hooks"; +import { cn } from "@/shared/lib/cn"; +import { Spinner } from "@/shared/ui/spinner"; +import type { + ConfigField, + ConfigOrigin, + ConfigWriteMechanism, + NormalizedConfig, + NormalizedField, +} from "@/shared/api/types"; + +type Props = { + pubkey: string; + isRunning: boolean; +}; + +// ── Provenance sentence ────────────────────────────────────────────────────── + +function provenanceSentence( + origin: ConfigOrigin, + writeVia: ConfigWriteMechanism, + configFilePath: string | null, +): string { + switch (origin) { + case "buzzExplicit": + return "Set in Buzz"; + case "personaDefault": + return "Inherited from persona"; + case "runtimeOverride": + return "Live override (this session only)"; + case "envVar": { + if (writeVia.type === "respawnWithEnvVar") { + return `From environment variable (${writeVia.envKey})`; + } + return "From environment variable"; + } + case "configFile": + return configFilePath + ? `From config file (${configFilePath})` + : "From config file"; + case "acpConfigOption": + case "acpNativeRead": + return "From ACP session"; + } +} + +// ── 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}
+
+ {isPreSpawn && isAcpOnly ? ( + + Available after agent starts + + ) : ( + <> + {field.value ?? } + {field.overriddenValue && ( + + {field.overriddenValue} + + )} + + )} +
+ {field.value && ( +
+ {provenanceSentence(field.origin, field.writeVia, configFilePath)} +
+ )} +
+ ); +} + +// ── Advanced row ────────────────────────────────────────────────────────────── + +function AdvancedRow({ + field, + configFilePath, +}: { + field: ConfigField; + configFilePath: string | null; +}) { + return ( +
+
{field.label}
+
+ {field.value ?? ( + + )} +
+ {field.value && ( +
+ {provenanceSentence(field.origin, field.writeVia, configFilePath)} +
+ )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) { + const [advancedOpen, setAdvancedOpen] = React.useState(false); + + const { data, isLoading, error } = useAgentConfigSurface(pubkey); + + if (isLoading) { + return ( +
+ + Loading config… +
+ ); + } + + if (error || !data) { + return ( +

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

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

+ No config fields available. +

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

+ Configuration +

+ +
) : null} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 12950201e..ae9d767f2 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -1,10 +1,23 @@ import { ChevronDown } from "lucide-react"; +import { toast } from "sonner"; 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 { switchManagedAgentModel } from "@/shared/api/agentControl"; +import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome"; +import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -23,12 +36,28 @@ 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); const [needsRestart, setNeedsRestart] = React.useState(false); const [hasRequestedModels, setHasRequestedModels] = React.useState(false); + const isRunning = agent.status === "running" || agent.status === "deployed"; + const activeTurns = useActiveAgentTurns(agent.pubkey); + // A live switch rides the agent's running session(s) instead of persisting a + // new default. It applies only to a persona-linked running agent with at + // least one active turn — those are the channels the desktop can name in the + // `switch_model` frame (the ModelPicker has no other channel context). The + // harness then routes each named channel itself: a channel still mid-turn + // cancel-switch-requeues; one that finished between send and receipt takes + // the idle invalidate-and-reapply path. A persona-linked agent that is + // running but wholly idle has no nameable channel here, so it falls through + // to persisting the default (the only reachable lever from this surface). + const isLiveSwitch = + agent.personaId !== null && isRunning && activeTurns.length > 0; + const fetchModels = React.useCallback(async () => { setLoading(true); setError(null); @@ -49,9 +78,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,14 +104,74 @@ 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", + runtimeOverride: "live override", + }; + return labels[origin] ?? null; + }, [configSurface]); + + // Send a live `switch_model` frame to each channel the agent is working in + // and wait for the harness to acknowledge. Any single `unsupported_model` + // result rejects the whole pick immediately; all other statuses must arrive + // from every channel before resolving success. + const sendLiveSwitch = React.useCallback( + (modelId: string) => { + const channelIds = activeTurns.map((turn) => turn.channelId); + return awaitLiveSwitchOutcome({ + channelCount: channelIds.length, + modelId, + subscribe: (listener) => + subscribeControlResults(agent.pubkey, listener), + sendSwitches: async () => { + await Promise.all( + channelIds.map((channelId) => + switchManagedAgentModel(agent.pubkey, channelId, modelId), + ), + ); + }, + // No reply in time: treat as sent. The override still rides the + // requeued/next session; we just can't confirm synchronously. + scheduleTimeout: (onTimeout) => { + const timeout = window.setTimeout(onTimeout, 8_000); + return () => window.clearTimeout(timeout); + }, + }); + }, + [activeTurns, agent.pubkey], + ); + const handleModelChange = async (modelId: string) => { setSaving(true); + setError(null); try { + if (isLiveSwitch) { + const outcome = await sendLiveSwitch(modelId); + if (outcome === "unsupported") { + toast.error("That model isn't available for this agent."); + return; + } + toast.success("Model switched for this session."); + onModelChanged?.(); + return; + } + + // Non-live path (idle, stopped, or non-persona): persist the default. await updateManagedAgent({ pubkey: agent.pubkey, model: modelId === modelsData?.agentDefaultModel ? null : modelId, }); - if (agent.status === "running" || agent.status === "deployed") { + if (isRunning) { setNeedsRestart(true); } onModelChanged?.(); @@ -93,6 +194,11 @@ export function ModelPicker({ variant="ghost" > {displayLabel} + {modelOriginLabel ? ( + + ({modelOriginLabel}) + + ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 42a3739c3..87a2c8c86 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -14,6 +14,7 @@ import { MessageSquare, Pencil, Server, + Settings, Terminal, UserMinus, UserPlus, @@ -22,6 +23,7 @@ import { import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; +import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -241,7 +243,27 @@ export function ProfileSummaryView({ ) : null} {metadataFields.length > 0 ? ( - + +
+ + + + + Configuration + +
+ + + ) : null + } + /> ) : null} ); @@ -770,7 +792,13 @@ function buildOwnerFields({ return fields; } -function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { +function ProfileFieldGroup({ + fields, + footer, +}: { + fields: ProfileField[]; + footer?: React.ReactNode; +}) { const publicKeyLabel = "Public key"; const ownedByLabel = "Owned by"; const statusLabel = "Status"; @@ -802,6 +830,7 @@ function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { {orderedFields.map((field) => ( ))} + {footer} ); diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 923d93922..677f0ffad 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -11,3 +11,21 @@ export async function cancelManagedAgentTurn( }); return { status: "sent" }; } + +/** + * Send a live model-switch control frame to a running agent. The switch rides + * the harness's cancel-switch-requeue path (busy turn) or invalidate-and-reapply + * (idle); the outcome arrives asynchronously as a `control_result` observer + * frame, not as the return value here. This is fire-and-forget on the send side. + */ +export async function switchManagedAgentModel( + pubkey: string, + channelId: string, + modelId: string, +): Promise { + await sendAgentObserverControl(pubkey, { + type: "switch_model", + channelId, + modelId, + }); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 33291ffe6..c6bcdaeae 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -42,6 +42,9 @@ import type { CommandAvailability, InstallRuntimeResult, OpenDmInput, + RuntimeConfigSurface, + WriteConfigFieldRequest, + WriteConfigResult, } from "@/shared/api/types"; type RawIdentity = { @@ -1124,6 +1127,29 @@ export async function getAgentModels(pubkey: string) { return invokeTauri("get_agent_models", { pubkey }); } +export async function getAgentConfigSurface( + pubkey: string, +): Promise { + return invokeTauri("get_agent_config_surface", { + pubkey, + }); +} + +export async function writeAgentConfigField( + request: WriteConfigFieldRequest, +): Promise { + return invokeTauri("write_agent_config_field", { + request, + }); +} + +export async function putAgentSessionConfig( + pubkey: string, + payload: unknown, +): Promise { + return invokeTauri("put_agent_session_config", { pubkey, payload }); +} + type RawUpdateManagedAgentResponse = { agent: RawManagedAgent; profile_sync_error: string | null; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ef59385aa..ab5741556 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -410,6 +410,25 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; +/** + * Outcome of a live `switch_model` control frame, surfaced asynchronously via + * the agent's `control_result` observer frame. Busy path: `sent` (cancel + + * requeue on the new model) or `turn_ending` (oneshot already consumed this + * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. + */ +export type SwitchManagedAgentModelStatus = + | "sent" + | "turn_ending" + | "switched" + | "unsupported_model" + | "no_active_turn"; + +export type ControlResultFrame = { + type: "cancel_turn" | "switch_model"; + status: string; + modelId?: string; +}; + export type AcpAvailabilityStatus = | "available" | "adapter_missing" @@ -476,6 +495,102 @@ export type AgentModelInfo = { name: string | null; description: string | null; }; + +// ── Config bridge types ────────────────────────────────────────────────────── + +export type ConfigOrigin = + | "buzzExplicit" + | "acpNativeRead" + | "acpConfigOption" + | "envVar" + | "configFile" + | "personaDefault" + | "runtimeOverride"; + +export type ConfigWriteMechanism = + | { type: "respawnWithEnvVar"; envKey: string } + | { type: "acpSetConfigOption"; configId: string } + | { type: "acpSetSessionModel" } + | { type: "gooseNativeConfigWrite"; configKey: string } + | { type: "readOnly" }; + +export type NormalizedField = { + value: string | null; + origin: ConfigOrigin; + isWritable: boolean; + writeVia: ConfigWriteMechanism; + overriddenValue: string | null; + overriddenOrigin: ConfigOrigin | null; +}; + +export type ConfigFieldType = + | { type: "string" } + | { type: "number" } + | { type: "boolean" } + | { type: "enum"; options: string[] }; + +export type ConfigField = { + key: string; + label: string; + value: string | null; + origin: ConfigOrigin; + schemaType: ConfigFieldType; + isWritable: boolean; + writeVia: ConfigWriteMechanism; +}; + +export type ConfigTierStatus = "available" | "pending" | "notApplicable"; + +export type ConfigSourceReport = { + acpNative: ConfigTierStatus; + acpConfigOptions: ConfigTierStatus; + envVars: ConfigTierStatus; + configFile: ConfigTierStatus; + configFilePath: string | null; +}; + +export type NormalizedConfig = { + model: NormalizedField | null; + provider: NormalizedField | null; + mode: NormalizedField | null; + thinkingEffort: NormalizedField | null; + maxOutputTokens: NormalizedField | null; + contextLimit: NormalizedField | null; + systemPrompt: NormalizedField | null; +}; + +export type RuntimeConfigSurface = { + runtimeId: string | null; + runtimeLabel: string | null; + isPreSpawn: boolean; + normalized: NormalizedConfig; + advanced: ConfigField[]; + sources: ConfigSourceReport; +}; + +export type WriteConfigTarget = + | { type: "model" } + | { type: "provider" } + | { type: "mode" } + | { type: "thinkingEffort" } + | { type: "maxOutputTokens" } + | { type: "contextLimit" } + | { type: "systemPrompt" } + | { type: "advanced"; key: string }; + +export type WriteConfigFieldRequest = { + pubkey: string; + field: WriteConfigTarget; + value: string | null; +}; + +export type WriteConfigResult = { + success: boolean; + mechanismUsed: ConfigWriteMechanism; + requiresRestart: boolean; + error: string | null; +}; + export type UpdateManagedAgentInput = { pubkey: string; name?: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ba2148d25..b581dfacf 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1000,6 +1000,428 @@ 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", + }, + }; + + // Live runtime override — a persona-linked agent whose session model was + // switched at runtime. The live model rides over the persona baseline as a + // secondary value WITHOUT strikethrough (the headline runtimeOverride render). + const runtimeOverrideSurface = { + runtimeId: "goose", + runtimeLabel: "Goose", + isPreSpawn: false, + normalized: { + model: { + value: "claude-opus-4-20250514", + origin: "runtimeOverride", + isWritable: true, + writeVia: { type: "acpSetSessionModel" }, + overriddenValue: "gpt-4o", + overriddenOrigin: "personaDefault", + }, + provider: { + value: "anthropic", + origin: "acpConfigOption", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "auto", + origin: "envVar", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: "high", + origin: "configFile", + isWritable: true, + writeVia: { + type: "gooseNativeConfigWrite", + configKey: "GOOSE_THINKING_EFFORT", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + sources: { + acpNative: "available", + acpConfigOptions: "available", + envVars: "available", + configFile: "available", + configFilePath: "~/.config/goose/config.yaml", + }, + }; + + // Mixed-provenance showcase — every top-level row carries a DIFFERENT origin + // so the panel witnesses four distinct provenance sentences in one frame: + // "Set in Buzz", "Inherited from persona", "From config file (...)", and + // "From environment variable (...)". + const multiOriginSurface = { + runtimeId: "goose", + runtimeLabel: "Goose", + isPreSpawn: false, + normalized: { + model: { + value: "gpt-4o", + origin: "buzzExplicit", + isWritable: true, + writeVia: { type: "acpSetSessionModel" }, + overriddenValue: null, + overriddenOrigin: null, + }, + provider: { + value: "openai", + origin: "personaDefault", + isWritable: false, + writeVia: { type: "readOnly" }, + overriddenValue: null, + overriddenOrigin: null, + }, + mode: { + value: "auto", + origin: "envVar", + isWritable: true, + writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" }, + overriddenValue: null, + overriddenOrigin: null, + }, + thinkingEffort: { + value: "medium", + origin: "configFile", + isWritable: true, + writeVia: { + type: "gooseNativeConfigWrite", + configKey: "GOOSE_THINKING_EFFORT", + }, + overriddenValue: null, + overriddenOrigin: null, + }, + maxOutputTokens: null, + contextLimit: null, + systemPrompt: null, + }, + advanced: [], + sources: { + acpNative: "available", + acpConfigOptions: "available", + envVars: "available", + configFile: "available", + configFilePath: "~/.config/goose/config.yaml", + }, + }; + + // Map well-known test pubkeys to specific fixtures + const PUBKEY_CLAUDE = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + const PUBKEY_PRESPAWN = + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; + const PUBKEY_CODEX = + "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; + const PUBKEY_RUNTIME_OVERRIDE = + "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e"; + // Synthetic agent for the multi-origin provenance showcase (not a TEST_IDENTITY). + const PUBKEY_MULTI_ORIGIN = + "abc1230000000000000000000000000000000000000000000000000000000def"; + + switch (pubkey) { + case PUBKEY_CLAUDE: + return claudeSurface; + case PUBKEY_PRESPAWN: + return preSpawnSurface; + case PUBKEY_CODEX: + return codexSurface; + case PUBKEY_RUNTIME_OVERRIDE: + return runtimeOverrideSurface; + case PUBKEY_MULTI_ORIGIN: + return multiOriginSurface; + default: + return gooseSurface; + } +} + function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { const now = new Date().toISOString(); const status = seed.status ?? "stopped"; @@ -6725,6 +7147,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..62c565b6d --- /dev/null +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -0,0 +1,272 @@ +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 PRESPAWN_PUBKEY = TEST_IDENTITIES.bob.pubkey; +const RUNTIME_OVERRIDE_PUBKEY = TEST_IDENTITIES.outsider.pubkey; +// Synthetic agent whose config surface mixes four distinct provenance origins +// (matches PUBKEY_MULTI_ORIGIN in e2eBridge buildMockConfigSurface). +const MULTI_ORIGIN_PUBKEY = + "abc1230000000000000000000000000000000000000000000000000000000def"; + +const MANAGED_AGENTS = [ + { pubkey: GOOSE_PUBKEY, name: "Goose Agent", status: "running" as const }, + { + pubkey: PRESPAWN_PUBKEY, + name: "Pre-Spawn Agent", + status: "stopped" as const, + }, + { + pubkey: RUNTIME_OVERRIDE_PUBKEY, + name: "Runtime Override Agent", + status: "running" as const, + }, + { + pubkey: MULTI_ORIGIN_PUBKEY, + name: "Multi-Origin Agent", + status: "running" as const, + }, +]; + +async function waitForInvokeBridge(page: import("@playwright/test").Page) { + 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, + }); +} + +// Settle any in-flight Radix/expand animations on the agent row before a +// capture so screenshots are deterministic (team-management-screenshots pattern). +async function settleAnimations( + page: import("@playwright/test").Page, + pubkey: string, +) { + await page + .getByTestId(`managed-agent-${pubkey}`) + .evaluate((el) => + Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), + ); +} + +test.describe("config bridge screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01 — folded config panel", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, GOOSE_PUBKEY); + + // The folded config panel: provenance sentences inline under each value, + // no origin badges, no sources footer. + const agentRow = page.getByTestId(`managed-agent-${GOOSE_PUBKEY}`); + await expect(agentRow.getByText("Set in Buzz")).toBeVisible(); + await settleAnimations(page, GOOSE_PUBKEY); + + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/01-folded-config-panel.png` }); + }); + + test("02 — live runtime override", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, RUNTIME_OVERRIDE_PUBKEY); + + // The headline new behavior: a runtimeOverride model shows the live model, + // the persona baseline as a NON-struck secondary value, and the + // "Live override (this session only)" sentence. + const agentRow = page.getByTestId( + `managed-agent-${RUNTIME_OVERRIDE_PUBKEY}`, + ); + await expect( + agentRow.getByText("Live override (this session only)"), + ).toBeVisible(); + await expect(agentRow.getByText("gpt-4o", { exact: true })).toBeVisible(); + await settleAnimations(page, RUNTIME_OVERRIDE_PUBKEY); + + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/02-live-runtime-override.png` }); + }); + + test("03 — provenance sentences", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, MULTI_ORIGIN_PUBKEY); + + // Each row carries a DIFFERENT inline provenance sentence so the frame + // witnesses multiple distinct origins at once: "Set in Buzz" (model), + // "Inherited from persona" (provider), "From environment variable + // (GOOSE_MODE)" (mode), and "From config file (...)" (thinking/effort). + const agentRow = page.getByTestId(`managed-agent-${MULTI_ORIGIN_PUBKEY}`); + await expect(agentRow.getByText("Set in Buzz")).toBeVisible(); + await expect(agentRow.getByText("Inherited from persona")).toBeVisible(); + await expect( + agentRow.getByText("From environment variable (GOOSE_MODE)"), + ).toBeVisible(); + await expect( + agentRow + .getByText("From config file (~/.config/goose/config.yaml)") + .first(), + ).toBeVisible(); + await settleAnimations(page, MULTI_ORIGIN_PUBKEY); + + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/03-provenance-sentences.png` }); + }); + + test("04 — pre-spawn state", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, PRESPAWN_PUBKEY); + + // ACP-only fields show "Available after agent starts" before spawn. + const agentRow = page.getByTestId(`managed-agent-${PRESPAWN_PUBKEY}`); + await expect( + agentRow.getByText("Available after agent starts").first(), + ).toBeVisible(); + await settleAnimations(page, PRESPAWN_PUBKEY); + + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/04-pre-spawn-state.png` }); + }); + + test("05 — advanced expanded", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openAgentsView(page); + await expandAgent(page, GOOSE_PUBKEY); + + 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, then settle the expand animation. + await expect(agentRow.getByText("Extension: developer")).toBeVisible(); + await settleAnimations(page, GOOSE_PUBKEY); + + await agentRow + .getByTestId("managed-agent-log-row") + .screenshot({ path: `${SHOTS}/05-advanced-expanded.png` }); + }); + + test("06 — profile side panel — Configuration section", async ({ page }) => { + // charlie (554cef…) is the well-known test pubkey that the mock bridge + // seeds as a bot owned by the test viewer, so isBot + isOwner + managedAgent + // are all true — the Configuration section renders in the profile panel. + await installMockBridge(page, { + managedAgents: [ + { + pubkey: + "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea", + name: "Charlie", + status: "running" as const, + channelNames: ["agents"], + }, + ], + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForInvokeBridge(page); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + // Click the agent avatar in the message row to open the profile side panel. + await page + .getByTestId("message-row") + .last() + .getByRole("button") + .first() + .click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + + // Wait for the Configuration section to render and scroll it into view so + // it is fully visible before capture. + const configHeading = panel.getByText("Configuration"); + await expect(configHeading).toBeVisible({ timeout: 10_000 }); + await configHeading.scrollIntoViewIfNeeded(); + + // Settle any in-flight animations before capture. + await panel.evaluate((el) => + Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), + ); + + await panel.screenshot({ path: `${SHOTS}/06-profile-side-panel-config.png` }); + }); +});