diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 3e2361a11..3e939c015 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1203,6 +1203,43 @@ 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 { fn drop(&mut self) { // Best-effort SIGKILL + reap. We cannot `await` in Drop (sync context). @@ -1755,6 +1792,60 @@ 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] fn idle_timeout_error_includes_duration() { let err = AcpError::IdleTimeout(std::time::Duration::from_secs(320)); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index e4b68dffe..c2d1a4eba 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}; @@ -710,11 +710,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()) @@ -743,6 +757,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. @@ -1035,6 +1126,7 @@ async fn tokio_main() -> Result<()> { state: SessionState::default(), model_capabilities: None, desired_model: config.model.clone(), + model_overridden: false, protocol_version, })); } @@ -1455,6 +1547,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); @@ -2115,8 +2208,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; } } @@ -3421,6 +3514,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 523d5cf74..6c8a19f91 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; @@ -132,6 +132,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, @@ -173,15 +178,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, @@ -190,6 +204,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. @@ -396,6 +427,63 @@ impl AgentPool { } count } + + /// Idle-path model switch: set `desired_model` on the idle agent for + /// `channel_id` and invalidate its session so the next turn re-creates the + /// session under the new model. + /// + /// Pre-cancel guard: the desired model is validated against the agent's + /// cached catalog *before* the session is invalidated, so an unsupported + /// pick is rejected without disturbing the existing session. + /// + /// Returns [`IdleSwitchResult`] describing what happened. The model does not + /// take effect — and the panel does not reflect it — until the agent next + /// runs a turn (no live session exists to re-emit `session_config_captured` + /// from an idle agent). This lag is intentional: faking the emit would + /// surface an override the session has not actually applied. + pub fn switch_idle_agent_model( + &mut self, + channel_id: Uuid, + model_id: &str, + ) -> IdleSwitchResult { + let Some(agent) = self + .agents + .iter_mut() + .flatten() + .find(|a| a.state.sessions.contains_key(&channel_id)) + else { + return IdleSwitchResult::NoIdleAgent; + }; + + // Pre-cancel guard against the cached catalog. None = catalog not yet + // populated (no session ever created); defer validation to apply time. + if let Some(caps) = agent.model_capabilities.as_ref() { + if !model_in_catalog( + &caps.config_options_raw, + caps.available_models_raw.as_ref(), + model_id, + ) { + return IdleSwitchResult::UnsupportedModel; + } + } + + agent.desired_model = Some(model_id.to_string()); + agent.model_overridden = true; + agent.state.invalidate_channel(&channel_id); + IdleSwitchResult::Switched + } +} + +/// 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). @@ -456,19 +544,52 @@ async fn create_session_and_apply_model( } // Apply desired_model if set, matching against the fresh session/new response. - if let Some(ref desired) = agent.desired_model { + // Track whether the switch succeeded so session_config_captured reflects + // the post-switch state (not the pre-switch desired state). + let switch_succeeded = if let Some(ref desired) = agent.desired_model { match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; + true } None => { tracing::warn!( 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, + }), + ); + false } } - } + } else { + false + }; + + // Emit session config for desktop consumption (config bridge tier 1b). + // Emitted AFTER desired_model resolution so the desktop caches the + // post-switch state. modelOverridden reflects whether the switch actually + // applied — false on the unsupported arm so the panel doesn't show a + // stale override badge. + 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 && switch_succeeded, + }), + ); // Apply permission mode if not the agent's built-in default AND the agent // advertises the requested mode in session/new. Agents that don't support @@ -1203,6 +1324,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() { @@ -1218,9 +1347,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, @@ -1232,9 +1362,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, @@ -1247,9 +1378,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, @@ -1261,9 +1393,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, @@ -1289,10 +1422,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!( @@ -1303,7 +1439,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, @@ -2901,7 +3037,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)); @@ -2922,7 +3058,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"); @@ -3045,6 +3181,39 @@ 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 // future is dropped. We assert what the observer saw. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5d4136277..12ccc2db0 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ "**/channel-controls.spec.ts", "**/active-turn-resilience.spec.ts", "**/profile-active-turn.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 6f0281e9b..7de789c2e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -36,7 +36,9 @@ const overrides = new Map([ // rebase, queued to split with the rest of this list. // persona-refresh-on-spawn: re-snapshot + retain_managed_agent_pending call // in start_local_agent_with_preflight adds ~23 lines. Queued to split. - ["src-tauri/src/commands/agents.rs", 1380], + // rebase onto main (2026-06-25): main's agents.rs grew by ~17 lines since + // branch cut; override bumped to cover the merged total. Queued to split. + ["src-tauri/src/commands/agents.rs", 1397], // 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 @@ -63,13 +65,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 @@ -84,6 +86,10 @@ const overrides = new Map([ // 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. @@ -92,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 8a0e52ce1..50fa47f11 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -904,6 +904,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 e14cfaf4e..79fc44f52 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -74,6 +74,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 4f595c348..07826caf5 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..d6642ea79 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -0,0 +1,591 @@ +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + config_bridge::{ + reader::read_config_surface, + types::{ + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, + NormalizedField, RuntimeConfigSurface, SessionConfigCache, + }, + }, + effective_agent_command, current_instance_id, 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. +/// +/// 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.clone(); + } + 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); + } + + // Re-tag persona-snapshotted model from BuzzExplicit to PersonaDefault. + // Persona-created agents have record.model set at create time from the + // persona snapshot — had_model is true, but the model came from the persona, + // not an explicit user choice. Re-tag when the record model matches the + // persona model and no live override is active. Only applies when a persona + // is actually linked — non-persona agents with an explicit model keep BuzzExplicit. + if had_model && !model_overridden && record.persona_id.is_some() { + if let (Some(ref record_model), Some(ref persona_model_val)) = + (&record.model, &persona_model) + { + if record_model == persona_model_val { + retag_persona_default(&mut surface.normalized.model); + } + } + } + + 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())?; + let (sync_changed, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { + save_managed_agents(&app, &records)?; + } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } + 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 effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + &personas, + record.agent_command_override.as_deref(), + ); + let runtime_meta = known_acp_runtime(&effective_cmd); + let session_cache = state.get_session_cache(&pubkey); + + Ok(resolve_config_surface( + record, + &personas, + runtime_meta, + session_cache.as_ref(), + )) +} + +/// 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"), + required_normalized_fields: &["model", "provider"], + } + } + + 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(), + } + } + + /// 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/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index d0261714e..124f9add3 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -37,9 +37,14 @@ pub async fn get_agent_models( .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } let record = records .iter() @@ -166,7 +171,11 @@ pub async fn update_managed_agent( .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + let (_, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } let record = find_managed_agent_mut(&mut records, &input.pubkey)?; diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index bb372487e..b87d13714 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -27,9 +27,13 @@ pub fn set_managed_agent_start_on_app_launch( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } { let record = find_managed_agent_mut(&mut records, &pubkey)?; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 706045a32..cd342389b 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -8,12 +8,11 @@ use crate::{ ensure_persona_is_active, 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, @@ -290,34 +289,66 @@ async fn start_local_agent_with_preflight( /// Build the standard agent JSON payload for provider deploy calls. /// -/// Reads the agent's pinned record snapshot — `env_vars`, `model`, `provider`, -/// `agent_command`/`agent_args` were all captured from the persona at create -/// time and never re-read live, so a provider-backed agent pins identically to a -/// local one. A persona edit reaches it only via delete+respawn. The only -/// read-time resolution is `relay_url`: a blank pin resolves to the active -/// workspace relay here, matching the create-path contract that stores an empty -/// override and defers the workspace fallback to read-time. +/// Unlike local spawn (which uses only pinned `record.env_vars` for +/// determinism), provider deploy re-reads live persona env vars and +/// structured model/provider so remote agents receive current credentials +/// and the same authoritative values that local spawn derives from +/// `runtime_metadata_env_vars`. The only field still pinned is +/// `agent_command`/`agent_args` — those were captured at create time. +/// The only read-time resolution is `relay_url`: a blank pin resolves to +/// the active workspace relay here, matching the create-path contract. /// -/// Fails closed when the private key is unavailable (keyring outage leaves it -/// empty after hydration): without this guard a provider deploy would serialize -/// `"private_key_nsec": ""` and launch the agent with no identity — the same -/// hazard the local spawn path refuses via `spawn_key_refusal`. +/// Fails closed when the private key is unavailable (keyring outage leaves +/// it empty after hydration): without this guard a provider deploy would +/// serialize `"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); + // Fails closed when the private key is unavailable — same guard as local + // spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""` + // and launch the agent with no identity. + if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { + return Err(err); } - // 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. Provider + // deploy re-reads live persona env vars so remote agents receive current + // credentials; local spawn uses only pinned record.env_vars for determinism + // across restarts. 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, @@ -335,8 +366,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, @@ -433,9 +467,13 @@ pub fn list_managed_agents( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } let personas = load_personas(&app).unwrap_or_default(); records @@ -497,9 +535,13 @@ pub async fn create_managed_agent( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } if let Some(persona_id) = requested_persona_id.as_deref() { let personas = load_personas(&app)?; ensure_persona_is_active(&personas, persona_id)?; @@ -563,9 +605,13 @@ pub async fn create_managed_agent( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } // Guard against a duplicate pubkey appearing between phase 1 and phase 3 // (extremely unlikely but safe to check). @@ -854,7 +900,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, @@ -949,9 +995,13 @@ pub async fn start_managed_agent( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } let record = find_managed_agent_mut(&mut records, &pubkey)?; @@ -982,7 +1032,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)?, } }; @@ -1205,9 +1255,13 @@ pub fn stop_managed_agent( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } { let record = find_managed_agent_mut(&mut records, &pubkey)?; @@ -1220,6 +1274,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() @@ -1247,9 +1302,13 @@ pub fn delete_managed_agent( .lock() .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)) { + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { save_managed_agents(&app, &records)?; } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } // Guard: reject deletion of deployed remote agents unless explicitly forced. // This turns "don't orphan remote infra" from a UI convention into a backend @@ -1269,10 +1328,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 e8a2756eb..88dba7773 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; @@ -29,6 +30,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 82b5caa0c..ddb6afd42 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -508,6 +508,8 @@ pub fn run() { delete_managed_agent, get_managed_agent_log, get_agent_models, + get_agent_config_surface, + 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..5e64c0c74 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -0,0 +1,205 @@ +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"); + + // effortLevel → thinking_effort (direct mapping per spec) + cfg.thinking_effort = json_string(s, "effortLevel"); + + // Config-driven extra fields — skip normalized keys to avoid double-counting. + let skip = &["model", "effortLevel"]; + cfg.extra = super::schema_walker::extract_config_fields(s, skip); + } + + // 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; + + 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::*; + + /// Parse a settings JSON string into a RuntimeFileConfig using the same + /// logic as read_config_file but without touching the filesystem. + fn parse_settings(json: &str) -> RuntimeFileConfig { + let val: serde_json::Value = serde_json::from_str(json).unwrap(); + let mut cfg = RuntimeFileConfig::default(); + cfg.model = json_string(&val, "model"); + cfg.thinking_effort = json_string(&val, "effortLevel"); + let skip = &["model", "effortLevel"]; + cfg.extra = super::super::schema_walker::extract_config_fields(&val, skip); + cfg + } + + #[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 effort_level_maps_to_thinking_effort() { + let cfg = parse_settings(r#"{"effortLevel": "high"}"#); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + // effortLevel must NOT appear in extra (it's in the skip list) + assert!(!cfg.extra.contains_key("effortLevel")); + } + + #[test] + fn always_thinking_enabled_appears_in_extra() { + let cfg = parse_settings(r#"{"alwaysThinkingEnabled": true}"#); + assert_eq!( + cfg.extra.get("alwaysThinkingEnabled").map(|s| s.as_str()), + Some("true"), + "alwaysThinkingEnabled should appear in extra" + ); + } + + #[test] + fn env_vars_flattened_in_extra() { + let cfg = parse_settings( + r#"{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "high", "ANTHROPIC_MODEL": "claude-opus-4"}}"#, + ); + assert_eq!( + cfg.extra + .get("env.CLAUDE_CODE_EFFORT_LEVEL") + .map(|s| s.as_str()), + Some("high"), + "env.CLAUDE_CODE_EFFORT_LEVEL should appear in extra" + ); + assert_eq!( + cfg.extra.get("env.ANTHROPIC_MODEL").map(|s| s.as_str()), + Some("claude-opus-4"), + "env.ANTHROPIC_MODEL should appear in extra" + ); + } + + #[test] + fn arbitrary_env_var_surfaced_without_schema() { + // Config-driven: any env var the user has set appears, even if no schema + // defines it — this is the core benefit over the schema-driven approach. + let cfg = parse_settings(r#"{"env": {"MY_CUSTOM_VAR": "hello"}}"#); + assert_eq!( + cfg.extra.get("env.MY_CUSTOM_VAR").map(|s| s.as_str()), + Some("hello"), + "arbitrary env vars should appear in extra" + ); + } + + #[test] + fn enabled_plugins_flattened_in_extra() { + let cfg = parse_settings( + r#"{"enabledPlugins": {"plugin-a": true, "plugin-b": true}}"#, + ); + // Walker flattens one level: enabledPlugins.plugin-a = "true" + assert!( + cfg.extra.contains_key("enabledPlugins.plugin-a") + || cfg.extra.contains_key("enabledPlugins.plugin-b"), + "enabledPlugins entries should appear as enabledPlugins. in extra" + ); + } + + #[test] + fn parse_permissions_and_hooks() { + let cfg = parse_settings( + r#"{"permissions": {"default": "bypassPermissions"}, "hooks": {"pre-commit": {}}}"#, + ); + // permissions is an object — flattened as permissions.default + assert_eq!( + cfg.extra.get("permissions.default").map(|s| s.as_str()), + Some("bypassPermissions") + ); + // hooks.pre-commit is an empty object — emits placeholder + assert_eq!( + cfg.extra.get("hooks.pre-commit").map(|s| s.as_str()), + Some("{...}") + ); + } + + #[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.thinking_effort.is_none()); + assert!(cfg.system_prompt.is_none()); + } + + #[test] + fn model_not_duplicated_in_extra() { + let cfg = parse_settings(r#"{"model": "claude-opus-4", "effortLevel": "high"}"#); + assert!(!cfg.extra.contains_key("model")); + assert!(!cfg.extra.contains_key("effortLevel")); + } + + #[test] + fn unknown_future_field_appears_in_extra() { + // Config-driven: any field the user has set appears, even if we've never + // heard of it. No schema gate. + let cfg = parse_settings(r#"{"someNewClaudeField": "value"}"#); + assert_eq!( + cfg.extra.get("someNewClaudeField").map(|s| s.as_str()), + Some("value"), + "unknown future fields should appear in extra" + ); + } +} 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..5117d2b3a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -0,0 +1,315 @@ +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, + }; + + // MCP servers from [mcp_servers.] tables + let extensions = parse_mcp_servers(&table); + + // Config-driven extra fields — skip normalized keys to avoid double-counting. + // The skip list covers fields extracted into typed struct fields above. + let config_json = toml_to_json(&toml::Value::Table(table)); + let skip = &[ + "model", + "model_provider", + "approval_policy", + "sandbox_mode", + "model_reasoning_effort", + "model_context_window", + "instructions", + "mcp_servers", + "model_providers", + ]; + let mut extra = super::schema_walker::extract_config_fields(&config_json, skip); + + // Custom model providers from [model_providers.] — surface as + // "model_providers. = configured" rather than flattening their internals. + if let Some(serde_json::Value::Object(providers)) = config_json.get("model_providers") { + for (name, _) in providers { + extra.insert(format!("model_providers.{name}"), "configured".to_string()); + } + } + + Some(RuntimeFileConfig { + model, + // Default to OpenAI when no provider is configured — that is Codex's + // implicit provider when model_provider is absent. + provider: model_provider.or_else(|| Some("openai".to_string())), + mode, + thinking_effort: reasoning_effort, + max_output_tokens: None, + context_limit: context_window, + system_prompt: toml_to_json_string(&config_json, "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() +} + +/// Convert a TOML value to a serde_json Value. +fn toml_to_json(val: &toml::Value) -> serde_json::Value { + match val { + toml::Value::String(s) => serde_json::Value::String(s.clone()), + toml::Value::Integer(i) => serde_json::Value::Number((*i).into()), + toml::Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + toml::Value::Boolean(b) => serde_json::Value::Bool(*b), + toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()), + toml::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().map(toml_to_json).collect()) + } + toml::Value::Table(tbl) => { + let map = tbl + .iter() + .map(|(k, v)| (k.clone(), toml_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + } +} + +/// Extract a string value from a JSON object by key (mirrors toml_string). +fn toml_to_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) +} + +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()); + // No model_provider → defaults to openai + assert_eq!(cfg.provider.as_deref(), Some("openai")); + assert!(cfg.mode.is_none()); + } + + #[test] + fn explicit_provider_wins_over_default() { + let toml = r#"model_provider = "azure""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("azure")); + } + + #[test] + fn invalid_toml_returns_none() { + assert!(parse_codex_config("{{{{not valid").is_none()); + } + + #[test] + fn extra_contains_fast_mode_from_features() { + // features is a nested table — walker flattens to features.fast_mode + let toml = r#" +model = "o3" + +[features] +fast_mode = true +"#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!( + cfg.extra.get("features.fast_mode").map(|s| s.as_str()), + Some("true"), + "features.fast_mode should appear in extra as 'true'" + ); + } + + #[test] + fn extra_contains_service_tier() { + let toml = r#"service_tier = "flex""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!( + cfg.extra.get("service_tier").map(|s| s.as_str()), + Some("flex"), + "service_tier should appear in extra" + ); + } + + #[test] + fn extra_contains_plan_mode_reasoning_effort() { + let toml = r#"plan_mode_reasoning_effort = "medium""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!( + cfg.extra + .get("plan_mode_reasoning_effort") + .map(|s| s.as_str()), + Some("medium"), + "plan_mode_reasoning_effort should appear in extra" + ); + } + + #[test] + fn extra_contains_model_reasoning_summary() { + let toml = r#"model_reasoning_summary = "auto""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!( + cfg.extra + .get("model_reasoning_summary") + .map(|s| s.as_str()), + Some("auto"), + "model_reasoning_summary should appear in extra" + ); + } + + #[test] + fn extra_contains_unknown_future_field() { + // Config-driven: any key the user has set appears, even if we've never + // heard of it. This is the core benefit of the config-driven approach. + let toml = r#"some_new_codex_field = "value""#; + let cfg = parse_codex_config(toml).unwrap(); + assert_eq!( + cfg.extra.get("some_new_codex_field").map(|s| s.as_str()), + Some("value"), + "unknown future fields should appear in extra" + ); + } + + #[test] + fn normalized_fields_not_duplicated_in_extra() { + let toml = r#" +model = "o3" +model_provider = "openai" +approval_policy = "unless-allow-listed" +sandbox_mode = "permissive" +model_reasoning_effort = "high" +model_context_window = "128000" +instructions = "You are helpful." +"#; + let cfg = parse_codex_config(toml).unwrap(); + // None of the normalized/skip fields should appear in extra + for key in &[ + "model", + "model_provider", + "approval_policy", + "sandbox_mode", + "model_reasoning_effort", + "model_context_window", + "instructions", + ] { + assert!( + !cfg.extra.contains_key(*key), + "normalized field '{key}' should not appear in extra" + ); + } + } +} 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..c06e2a69c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -0,0 +1,279 @@ +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 { + // TODO: replace hardcoded field extraction with schema_walker once goose publishes + // a JSON Schema. Tracked separately. + 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()).or_else(|| { + // Databricks OAuth path: flat DATABRICKS_HOST key is set but no explicit provider. + // The goose runtime uses Databricks implicitly in this case. + if yaml_string(&map, "DATABRICKS_HOST").is_some() { + Some("databricks".to_string()) + } else { + None + } + }); + 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()); + } + + #[test] + fn databricks_host_without_explicit_provider_infers_databricks() { + let yaml = r#" +DATABRICKS_HOST: https://block-lakehouse-production.cloud.databricks.com/ +GOOSE_TELEMETRY_ENABLED: false +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("databricks")); + } + + #[test] + fn explicit_provider_wins_over_databricks_inference() { + let yaml = r#" +GOOSE_PROVIDER: anthropic +DATABRICKS_HOST: https://block-lakehouse-production.cloud.databricks.com/ +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("anthropic")); + } +} 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..654ae4857 --- /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; +mod schema_walker; +pub(crate) mod types; + +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..980bfa1ae --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -0,0 +1,996 @@ +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() + .or_else(|| record.provider.clone()); // structured provider field as fallback + + 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); + let required_fields: &[&str] = runtime_meta + .map(|m| m.required_normalized_fields) + .unwrap_or(&[]); + + // Tier 1b: ACP configOptions from session cache. + // For unstable/switchable agents, current_model comes from the `models` + // field. For stable agents that only report model via configOptions + // (category="model", current_value), fall back to find_config_option_value + // so their current model is surfaced in the panel. + let acp_model = session_cache.and_then(|c| { + c.current_model + .clone() + .or_else(|| find_config_option_value(c, "model")) + }); + 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, + required_fields.contains(&"model"), + ), + acp_model.as_deref(), + baseline, + model_overridden, + )), + provider: build_provider_field( + &record_provider, + &file_config.provider, + provider_env_var, + provider_locked, + required_fields.contains(&"provider"), + ), + 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, + write_via: ConfigWriteMechanism::ReadOnly, + overridden_value: None, + overridden_origin: None, + is_required: false, + }), + context_limit: file_config.context_limit.as_ref().map(|v| NormalizedField { + value: Some(v.clone()), + origin: ConfigOrigin::ConfigFile, + write_via: ConfigWriteMechanism::ReadOnly, + overridden_value: None, + overridden_origin: None, + is_required: false, + }), + 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, + 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), + is_required: false, + }) + }, + }; + + // 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, + write_via: ConfigWriteMechanism::ReadOnly, + }) + .collect(); + + // Collect the env var keys already covered by normalized fields so we don't double-surface them. + let normalized_env_keys: Vec<&str> = [ + model_env_var, + provider_env_var, + thinking_env_var, + Some("BUZZ_ACP_SYSTEM_PROMPT"), + ] + .into_iter() + .flatten() + .collect(); + + // Tier 2a: remaining env vars not covered by normalized fields. + // Env var wins over config file for the same key (tier 2a > 2b), so skip + // keys already present in file_config.extra. + let mut advanced = advanced; + for (k, v) in &record.env_vars { + if normalized_env_keys.contains(&k.as_str()) { + continue; + } + if file_config.extra.contains_key(k) { + continue; // config file already surfaced this key + } + advanced.push(ConfigField { + key: k.clone(), + label: k.clone(), + value: Some(v.clone()), + origin: ConfigOrigin::BuzzExplicit, + schema_type: ConfigFieldType::String, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { env_key: k.clone() }, + }); + } + + 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>, + is_required: bool, +) -> 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 { + // No value from any tier. EnvVar is the sentinel origin for "no value + // resolved" — there is no dedicated None-origin variant. The panel + // renders this as an empty/absent field. + (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, + write_via, + overridden_value, + overridden_origin, + is_required, + } +} + +/// 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, + is_required: bool, +) -> Option { + if provider_locked { + return Some(NormalizedField { + value: Some("Anthropic (locked)".to_string()), + origin: ConfigOrigin::HarnessConstraint, + write_via: ConfigWriteMechanism::ReadOnly, + overridden_value: None, + overridden_origin: None, + is_required: false, + }); + } + + let tiers: &[(Option<&str>, ConfigOrigin)] = &[ + (record_provider.as_deref(), ConfigOrigin::BuzzExplicit), + (file_provider.as_deref(), ConfigOrigin::ConfigFile), + ]; + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + + 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, + write_via, + overridden_value, + overridden_origin, + is_required, + }) +} + +fn build_mode_field( + file_mode: &Option, + acp_mode: &Option, + is_pre_spawn: bool, + session_cache: Option<&SessionConfigCache>, +) -> Option { + let tiers: &[(Option<&str>, ConfigOrigin)] = &[ + (acp_mode.as_deref(), ConfigOrigin::AcpConfigOption), + (file_mode.as_deref(), ConfigOrigin::ConfigFile), + ]; + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + + 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, + write_via, + overridden_value, + overridden_origin, + is_required: false, + }) +} + +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 tiers: &[(Option<&str>, ConfigOrigin)] = &[ + (record_effort.as_deref(), ConfigOrigin::BuzzExplicit), + (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), + (file_effort.as_deref(), ConfigOrigin::ConfigFile), + ]; + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + + 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, + write_via, + overridden_value, + overridden_origin, + is_required: false, + }) +} + +/// Picks the first `Some` value from `tiers` (highest-precedence first) and +/// returns `(value, origin, overridden_value, overridden_origin)` where the +/// overridden pair is the next `Some` tier after the winner. Returns `None` +/// when no tier has a value. +fn resolve_with_override( + tiers: &[(Option<&str>, ConfigOrigin)], +) -> Option<( + Option, + ConfigOrigin, + Option, + Option, +)> { + let winner_idx = tiers.iter().position(|(v, _)| v.is_some())?; + let (value, origin) = &tiers[winner_idx]; + let value = value.map(str::to_string); + let origin = origin.clone(); + + // Overridden = the next Some after the winner. + let overridden = tiers[winner_idx + 1..].iter().find(|(v, _)| v.is_some()); + let (overridden_value, overridden_origin) = match overridden { + Some((v, o)) => (v.map(str::to_string), Some(o.clone())), + None => (None, None), + }; + + Some((value, origin, overridden_value, overridden_origin)) +} + +// ── 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"), + required_normalized_fields: &["model", "provider"], + } + } + + 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_eq!(provider.origin, ConfigOrigin::HarnessConstraint); + } + + #[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); + } + + #[test] + fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { + let mut record = test_record(); + // Normalized keys — must NOT appear in advanced. + record + .env_vars + .insert("GOOSE_MODEL".to_string(), "some-model".to_string()); + record + .env_vars + .insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), "hello".to_string()); + // Non-normalized key — MUST appear in advanced. + record + .env_vars + .insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, None); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + advanced_keys.contains(&"SPROUT_ACP_MEMORY"), + "extra env var must appear in advanced" + ); + assert!( + !advanced_keys.contains(&"GOOSE_MODEL"), + "normalized model key must not appear in advanced" + ); + assert!( + !advanced_keys.contains(&"BUZZ_ACP_SYSTEM_PROMPT"), + "normalized system prompt key must not appear in advanced" + ); + + let field = surface + .advanced + .iter() + .find(|f| f.key == "SPROUT_ACP_MEMORY") + .unwrap(); + assert_eq!(field.value.as_deref(), Some("mem-value")); + assert_eq!(field.origin, ConfigOrigin::BuzzExplicit); + assert!(matches!( + field.write_via, + ConfigWriteMechanism::RespawnWithEnvVar { ref env_key } if env_key == "SPROUT_ACP_MEMORY" + )); + } + + #[test] + fn extra_env_var_skipped_when_already_in_file_config_extra() { + // If a key is in both record.env_vars and file_config.extra, the config + // file entry wins (it was already added to advanced). The env var must + // not produce a second entry. + // + // We can't inject into file_config.extra directly in a unit test (it + // comes from disk), so we verify the dedup logic via the normalized-key + // path: GOOSE_THINKING_EFFORT is a normalized key and must not appear + // in advanced even if set in env_vars. + let mut record = test_record(); + record + .env_vars + .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, None); + + let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); + assert!( + !advanced_keys.contains(&"GOOSE_THINKING_EFFORT"), + "normalized thinking key must not appear in advanced" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/schema_walker.rs b/desktop/src-tauri/src/managed_agents/config_bridge/schema_walker.rs new file mode 100644 index 000000000..320ea8773 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/schema_walker.rs @@ -0,0 +1,292 @@ +use std::collections::BTreeMap; + +/// Walk a config JSON object and extract every key that is present, returning a +/// flat `BTreeMap` suitable for `RuntimeFileConfig::extra`. +/// +/// Keys in `skip` are excluded (used to avoid double-counting normalized fields +/// that are extracted into typed struct fields like `model`, `provider`, etc.). +/// +/// Value formatting: +/// - Scalar values (string, number, bool) → their string representation +/// - Arrays → "[N items]" +/// - Objects → flatten up to two levels deep as "key.subkey" or +/// "key.subkey.subsubkey = value"; deeper nesting → "{...}". +/// Note: object subkeys are iterated from the config value, not filtered against the +/// schema's nested properties — so all subkeys the user has set are surfaced regardless +/// of whether the schema defines them (intentional: supports arbitrary keys like env vars). +pub(super) fn extract_config_fields( + config: &serde_json::Value, + skip: &[&str], +) -> BTreeMap { + let mut out = BTreeMap::new(); + + let config_obj = match config.as_object() { + Some(o) => o, + None => return out, + }; + + for (key, value) in config_obj { + if skip.contains(&key.as_str()) { + continue; + } + match value { + serde_json::Value::Object(obj) => { + // Flatten up to two levels: "key.subkey" and "key.subkey.subsubkey" + for (subkey, subval) in obj { + let flat_key = format!("{key}.{subkey}"); + match subval { + serde_json::Value::Object(subobj) => { + // Second level: "key.subkey.subsubkey" + if subobj.is_empty() { + // Empty inner object — emit placeholder so the key is visible. + out.insert(flat_key, "{...}".to_string()); + } else { + for (subsubkey, subsubval) in subobj { + let deep_key = format!("{flat_key}.{subsubkey}"); + out.insert(deep_key, format_scalar(subsubval)); + } + } + } + serde_json::Value::Array(arr) => { + out.insert(flat_key, format_array(arr)); + } + other => { + out.insert(flat_key, format_scalar(other)); + } + } + } + } + serde_json::Value::Array(arr) => { + out.insert(key.clone(), format_array(arr)); + } + other => { + out.insert(key.clone(), format_scalar(other)); + } + } + } + + out +} + +fn format_scalar(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Array(arr) => format_array(arr), + serde_json::Value::Object(_) => "{...}".to_string(), + } +} + +/// Format an array for display. +/// +/// When all elements are scalars (string, bool, number, null), joins them +/// comma-separated so the actual values are visible (e.g. `tui.status_line`). +/// When any element is a nested object or array, falls back to `[N items]` +/// since the values can't be meaningfully inlined. +fn format_array(arr: &[serde_json::Value]) -> String { + let all_scalar = arr.iter().all(|v| { + matches!( + v, + serde_json::Value::String(_) + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::Null + ) + }); + if all_scalar { + arr.iter() + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + _ => unreachable!(), + }) + .collect::>() + .join(", ") + } else { + format!("[{} items]", arr.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extracts_scalar_string() { + let config = json!({ "name": "alice" }); + let result = extract_config_fields(&config, &[]); + assert_eq!(result.get("name").map(|s| s.as_str()), Some("alice")); + } + + #[test] + fn extracts_scalar_bool() { + let config = json!({ "enabled": true }); + let result = extract_config_fields(&config, &[]); + assert_eq!(result.get("enabled").map(|s| s.as_str()), Some("true")); + } + + #[test] + fn extracts_scalar_number() { + let config = json!({ "count": 42 }); + let result = extract_config_fields(&config, &[]); + assert_eq!(result.get("count").map(|s| s.as_str()), Some("42")); + } + + #[test] + fn formats_scalar_array_as_joined_values() { + let config = json!({ "tags": ["a", "b", "c"] }); + let result = extract_config_fields(&config, &[]); + assert_eq!(result.get("tags").map(|s| s.as_str()), Some("a, b, c")); + } + + #[test] + fn flattens_object_one_level() { + let config = json!({ "env": { "FOO": "bar", "BAR": "baz" } }); + let result = extract_config_fields(&config, &[]); + assert_eq!(result.get("env.FOO").map(|s| s.as_str()), Some("bar")); + assert_eq!(result.get("env.BAR").map(|s| s.as_str()), Some("baz")); + assert!(!result.contains_key("env")); + } + + #[test] + fn empty_inner_object_emits_placeholder() { + let config = json!({ "hooks": { "pre-commit": {} } }); + let result = extract_config_fields(&config, &[]); + assert_eq!( + result.get("hooks.pre-commit").map(|s| s.as_str()), + Some("{...}") + ); + } + + #[test] + fn nested_object_at_two_levels_is_flattened() { + let config = json!({ "nested": { "deep": { "value": 42 } } }); + let result = extract_config_fields(&config, &[]); + // Two levels deep: "nested.deep.value" = "42" + assert_eq!( + result.get("nested.deep.value").map(|s| s.as_str()), + Some("42") + ); + assert!(!result.contains_key("nested.deep")); + } + + #[test] + fn nested_object_beyond_two_levels_is_placeholder() { + let config = json!({ "a": { "b": { "c": { "d": 1 } } } }); + let result = extract_config_fields(&config, &[]); + // "a.b.c" is depth 3 — value is an object → "{...}" + assert_eq!(result.get("a.b.c").map(|s| s.as_str()), Some("{...}")); + } + + #[test] + fn projects_table_flattens_to_trust_level() { + // Mirrors codex [projects.""] { trust_level = "trusted" } + let config = json!({ + "projects": { + "/Users/foo/dev/buzz": { "trust_level": "trusted" }, + "/Users/foo/dev/other": { "trust_level": "untrusted" } + } + }); + let result = extract_config_fields(&config, &[]); + assert_eq!( + result + .get("projects./Users/foo/dev/buzz.trust_level") + .map(|s| s.as_str()), + Some("trusted") + ); + assert_eq!( + result + .get("projects./Users/foo/dev/other.trust_level") + .map(|s| s.as_str()), + Some("untrusted") + ); + } + + #[test] + fn tui_model_availability_nux_flattens_to_model_keys() { + // Mirrors codex [tui.model_availability_nux] { "gpt-5.5" = 1 } + let config = json!({ + "tui": { + "status_line": ["model-with-reasoning", "git-branch"], + "model_availability_nux": { "gpt-5.5": 1 } + } + }); + let result = extract_config_fields(&config, &[]); + assert_eq!( + result.get("tui.status_line").map(|s| s.as_str()), + Some("model-with-reasoning, git-branch") + ); + assert_eq!( + result + .get("tui.model_availability_nux.gpt-5.5") + .map(|s| s.as_str()), + Some("1") + ); + assert!(!result.contains_key("tui.model_availability_nux")); + } + + #[test] + fn skip_list_excludes_keys() { + let config = json!({ "model": "gpt-4", "extra": "value" }); + let result = extract_config_fields(&config, &["model"]); + assert!(!result.contains_key("model")); + assert!(result.contains_key("extra")); + } + + #[test] + fn unknown_keys_are_surfaced() { + // Config-driven: any key the user has set appears, no schema gate. + let config = json!({ "known": "yes", "unknown_future_field": "also yes" }); + let result = extract_config_fields(&config, &[]); + assert!(result.contains_key("known")); + assert!(result.contains_key("unknown_future_field")); + } + + #[test] + fn empty_config_returns_empty() { + let result = extract_config_fields(&json!({}), &[]); + assert!(result.is_empty()); + } + + #[test] + fn non_object_config_returns_empty() { + let result = extract_config_fields(&json!("not an object"), &[]); + assert!(result.is_empty()); + } + + #[test] + fn empty_array_formats_as_empty_string() { + let config = json!({ "list": [] }); + let result = extract_config_fields(&config, &[]); + // Empty array: all-scalar vacuously → join produces empty string + assert_eq!(result.get("list").map(|s| s.as_str()), Some("")); + } + + #[test] + fn array_with_nested_objects_falls_back_to_item_count() { + let config = json!({ "items": [{"key": "val"}, {"key": "val2"}] }); + let result = extract_config_fields(&config, &[]); + assert_eq!(result.get("items").map(|s| s.as_str()), Some("[2 items]")); + } + + #[test] + fn arbitrary_env_subkeys_surfaced_without_schema() { + // Env vars are arbitrary strings — all should appear regardless of whether + // any schema defines them. + let config = json!({ "env": { "MY_CUSTOM_VAR": "hello", "ANOTHER_VAR": "world" } }); + let result = extract_config_fields(&config, &[]); + assert_eq!( + result.get("env.MY_CUSTOM_VAR").map(|s| s.as_str()), + Some("hello") + ); + assert_eq!( + result.get("env.ANOTHER_VAR").map(|s| s.as_str()), + Some("world") + ); + } +} 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..c201141df --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -0,0 +1,201 @@ +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, + /// Value is fixed by the harness itself — not from any user-set config or + /// env var. E.g. Claude Code only supports Anthropic as a provider; the + /// "locked" display is synthesized by the config bridge, not read from disk. + HarnessConstraint, +} + +/// 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 write_via: ConfigWriteMechanism, + /// When this field overrides a lower-precedence value, show what it overrode. + pub overridden_value: Option, + pub overridden_origin: Option, + /// True if this field must be set for the harness to function. + /// Populated from `KnownAcpRuntime::required_normalized_fields`. + pub is_required: bool, +} + +/// 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 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, +} + +/// 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/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2e3518b50..4c8e3ffb2 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -42,6 +42,15 @@ 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>, + /// Normalized field keys that must be set for this harness to function. + /// Used by the config bridge to mark fields as required in the UI. + /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). + pub required_normalized_fields: &'static [&'static str], } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -93,6 +102,11 @@ 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"), + required_normalized_fields: &["model", "provider"], }, KnownAcpRuntime { id: "claude", @@ -114,6 +128,11 @@ 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, + required_normalized_fields: &[], }, KnownAcpRuntime { id: "codex", @@ -133,8 +152,13 @@ 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, + required_normalized_fields: &[], }, KnownAcpRuntime { id: "buzz-agent", @@ -156,6 +180,11 @@ 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, + required_normalized_fields: &["model", "provider"], }, ]; 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-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e5897d978..85f539112 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -107,7 +107,7 @@ pub async fn restore_managed_agents_on_launch( .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let mut changed = sync_managed_agent_processes( + let (mut changed, _exited) = sync_managed_agent_processes( &mut records, &mut runtimes, &super::current_instance_id(app), diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d7e271df3..1751baac6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1241,7 +1241,7 @@ pub fn sync_managed_agent_processes( records: &mut [ManagedAgentRecord], runtimes: &mut HashMap, instance_id: &str, -) -> bool { +) -> (bool, Vec) { let mut changed = false; let mut exited = Vec::new(); @@ -1281,6 +1281,7 @@ pub fn sync_managed_agent_processes( exited.push(pubkey.clone()); } + let mut exited_pubkeys: Vec = exited.clone(); for pubkey in exited { runtimes.remove(&pubkey); } @@ -1307,9 +1308,10 @@ pub fn sync_managed_agent_processes( record.last_stopped_at = Some(now_iso()); } changed = true; + exited_pubkeys.push(record.pubkey.clone()); } - changed + (changed, exited_pubkeys) } /// Classify an agent's persona against the live catalog for the Agents-menu diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index a87e1432c..532e005f8 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -18,7 +18,7 @@ pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), Stri .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let mut changed = sync_managed_agent_processes( + let (mut changed, _exited) = sync_managed_agent_processes( &mut records, &mut runtimes, &managed_agents::current_instance_id(app), 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 f2eb22f50..6aa397edb 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -2,9 +2,12 @@ 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 { useQueryClient } from "@tanstack/react-query"; +import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; import type { ConnectionState, ObserverEvent, @@ -38,6 +41,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). // @@ -49,6 +60,17 @@ const snapshotByAgent = new Map(); const knownAgentPubkeys = new Set(); const knownAgentsBySubscription = new Map>(); +// Callback invoked when session_config_captured is received, so React Query +// can invalidate the config-surface query for the affected agent. Wired up +// by useManagedAgentObserverBridge via setSessionConfigCapturedCallback. +let onSessionConfigCaptured: ((pubkey: string) => void) | null = null; + +export function setSessionConfigCapturedCallback( + cb: ((pubkey: string) => void) | null, +) { + onSessionConfigCaptured = cb; +} + function recomputeKnownAgentPubkeys() { knownAgentPubkeys.clear(); for (const subscriptionAgents of knownAgentsBySubscription.values()) { @@ -190,6 +212,12 @@ async function handleRelayObserverEvent( return; } appendAgentEvent(agentPubkey, parsed); + if (parsed.kind === "session_config_captured") { + void putAgentSessionConfig(agentPubkey, parsed.payload); + onSessionConfigCaptured?.(agentPubkey); + } else if (parsed.kind === "control_result") { + dispatchControlResult(agentPubkey, parsed.payload); + } } catch (error) { if (activeGeneration !== generation) { return; @@ -266,6 +294,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, @@ -336,6 +411,17 @@ export function useManagedAgentObserverBridge( } void ensureRelayObserverSubscription(); }, [hasActiveAgent]); + + // Wire up config-surface query invalidation when session_config_captured fires. + const queryClient = useQueryClient(); + React.useEffect(() => { + setSessionConfigCapturedCallback((pubkey) => { + void queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(pubkey), + }); + }); + return () => setSessionConfigCapturedCallback(null); + }, [queryClient]); } export function resetAgentObserverStore() { @@ -349,6 +435,7 @@ export function resetAgentObserverStore() { snapshotByAgent.clear(); knownAgentPubkeys.clear(); knownAgentsBySubscription.clear(); + onSessionConfigCaptured = null; connectionState = "idle"; errorMessage = null; notifyListeners(); diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx new file mode 100644 index 000000000..2af8476c2 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -0,0 +1,240 @@ +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; +}; + +// ── 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 "harnessConstraint": + return "Locked by harness"; + 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 }: 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 a91581f51..9e8fbcf4f 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -16,6 +16,7 @@ import type { } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { AgentConfigPanel } from "./AgentConfigPanel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { truncatePubkey } from "./agentUi"; @@ -171,6 +172,12 @@ 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..3c5c3a36a 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -1,10 +1,20 @@ import { ChevronDown } from "lucide-react"; +import { toast } from "sonner"; import { Spinner } from "@/shared/ui/spinner"; import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types"; import { 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 { + useAgentConfigSurface, + managedAgentsQueryKey, +} from "@/features/agents/hooks"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -29,6 +39,23 @@ export function ModelPicker({ const [needsRestart, setNeedsRestart] = React.useState(false); const [hasRequestedModels, setHasRequestedModels] = React.useState(false); + const { data: configSurface } = useAgentConfigSurface(agent.pubkey); + const queryClient = useQueryClient(); + + 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); @@ -63,14 +90,75 @@ 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") { + void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + if (isRunning) { setNeedsRestart(true); } onModelChanged?.(); @@ -93,6 +181,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 885df6eaa..5dcb3253d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -1,24 +1,32 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; import { + Activity, ArrowUpRight, + Brain, ChevronDown, + ChevronRight, ChevronUp, - CircleAlert, + Copy, + Cpu, + Fingerprint, + Hash, MessageSquare, Pencil, - Play, - Square, + Server, + Settings, + Terminal, UserMinus, UserPlus, + UserRound, } from "lucide-react"; 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 { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; -import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -27,287 +35,133 @@ import type { useUnfollowMutation, useUserProfileQuery, } from "@/features/profile/hooks"; -import { - type ProfileField, - ProfileFieldGroup, -} from "@/features/profile/ui/UserProfilePanelFields"; -import { AGENT_DETAILS_FIELD_LABELS } from "@/features/profile/ui/UserProfilePanelAgentDetails"; -import { - ProfileInfoTabContent, - ProfileIngressRow, - ProfileRuntimeTabContent, - ProfileTabBar, -} from "@/features/profile/ui/UserProfilePanelTabs"; +import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; -import { Spinner } from "@/shared/ui/spinner"; -import type { - ProfileChannelLink, - ProfilePanelTab, -} from "@/features/profile/ui/UserProfilePanelUtils"; import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; -import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + aider: "Aider", +}; + +function runtimeLabel(command: string): string { + return RUNTIME_LABELS[command] ?? command; +} -export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +async function copyToClipboard(value: string, label?: string) { + await navigator.clipboard.writeText(value); + toast.success(label ? `Copied ${label}` : "Copied to clipboard"); +} // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { - canAddToChannel: boolean; canEditAgent: boolean; - canOpenAgentLogs: boolean; canViewActivity: boolean; channelCount: number; channelIdToName: Record; - channels: ProfileChannelLink[]; channelsLoading: boolean; displayName: string; followMutation: ReturnType; - canInstantiateAgent: boolean; - agentInstruction: string | null; - handleAgentPrimaryAction: () => void; handleEditAgent: () => void; - handleEditPersona?: () => void; - handleInstantiateAgent: () => void; handleMessage: () => void; - isArchived: boolean; - isMessagePending: boolean; + handleOpenActivity: () => void; isBot: boolean; - isAgentActionPending: boolean; isFollowing: boolean; isOwner: boolean | undefined; isSelf: boolean; managedAgent: ManagedAgent | undefined; memoriesLoading: boolean; memoryCount: number | undefined; - modelLabel: string; - agentInfoFields: ProfileField[]; - agentSettingsFields: ProfileField[]; - diagnosticsFields: ProfileField[]; - onAddToChannel: () => void; - onOpenActivity: () => void; - onOpenChannel: (channelId: string) => void; - onOpenDiagnostics: () => void; - onOpenInstructions: () => void; - onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void; - onOpenDm?: (pubkeys: string[]) => Promise | void; + ownerDisplayName: string | null; + ownerAvatarUrl: string | null; + ownerHandle: string | null; + ownerPubkey: string | null; + onOpenChannels: () => void; + onOpenOwner?: () => void; + onOpenMemories: () => void; + onOpenDm?: (pubkeys: string[]) => void; + presenceLoaded: boolean; presenceStatus: "online" | "away" | "offline" | undefined; profile: ReturnType["data"]; - pubkey: string | null; + pubkey: string; relayAgent: RelayAgent | undefined; - tab: ProfilePanelTab; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; }; -type RuntimeTabStatus = "running" | "stopped" | "error"; - -function resolveRuntimeTabStatus({ - diagnosticsError, - managedAgent, -}: { - diagnosticsError: boolean; - managedAgent: ManagedAgent | undefined; -}): RuntimeTabStatus | undefined { - if (diagnosticsError || managedAgent?.lastError) { - return "error"; - } - - if (!managedAgent) { - return undefined; - } - - if (managedAgent.status === "running" || managedAgent.status === "deployed") { - return "running"; - } - - return "stopped"; -} - -function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) { - const label = - status === "error" ? "Error" : status === "running" ? "Running" : "Stopped"; - - return ( -