diff --git a/AGENTS.md b/AGENTS.md index 3a79c4b38d..e10e49e664 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -476,11 +476,18 @@ are frozen.** So for any readable text, reach for rem-based Tailwind tokens, never arbitrary px: -- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author - text === `text-base` (16px) — chat is the app's base type size**, and the - surrounding timeline elements (timestamps, system rows, code, reactions) are - deliberate steps on that same stock ramp. -- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text +- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general + interface text. All of these derive from the virtual typography rem and + therefore follow the user's font-size preference and Cmd +/- zoom. +- ✅ Conversation text uses the named `text-message` token. Its + **Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard + zoom. Author names use the same conversation-size step; timestamps, system + rows, code, and reactions are deliberate neighboring steps on the shared + virtual-rem ramp. Keep those relationships tokenized rather than restoring a + fixed 16px chat baseline or hardcoding preference-specific values in + components. +- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs` + (0.5rem / 8px at a 16px virtual rem) meta-text tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs. These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index fa3a565d49..b6d5a21dcb 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2244,6 +2244,28 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option Option { + let arr = result["configOptions"].as_array()?; + for opt in arr { + if opt.get("category").and_then(|c| c.as_str()) == Some("thought_level") { + let config_id = opt + .get("configId") + .or_else(|| opt.get("id")) + .and_then(|v| v.as_str())?; + return Some(config_id.to_string()); + } + } + None +} + /// Match a desired model ID against a fresh `session/new` response. /// /// Returns the correct ACP method to call, or `None` if no match. @@ -2868,6 +2890,54 @@ mod tests { assert!(super::extract_model_state(&result).is_none()); } + #[test] + fn extract_thought_level_config_id_finds_config_id() { + let result = serde_json::json!({ + "sessionId": "sess-1", + "configOptions": [ + { "configId": "model", "category": "model" }, + { + "configId": "effort", + "category": "thought_level", + "options": [{ "value": "high" }, { "value": "low" }] + } + ] + }); + assert_eq!( + super::extract_thought_level_config_id(&result).as_deref(), + Some("effort") + ); + } + + #[test] + fn extract_thought_level_config_id_falls_back_to_id_key() { + let result = serde_json::json!({ + "configOptions": [ + { "id": "effort", "category": "thought_level" } + ] + }); + assert_eq!( + super::extract_thought_level_config_id(&result).as_deref(), + Some("effort") + ); + } + + #[test] + fn extract_thought_level_config_id_none_without_category() { + let result = serde_json::json!({ + "configOptions": [ + { "configId": "model", "category": "model" } + ] + }); + assert!(super::extract_thought_level_config_id(&result).is_none()); + } + + #[test] + fn extract_thought_level_config_id_none_without_config_options() { + let result = serde_json::json!({ "sessionId": "sess-1" }); + assert!(super::extract_thought_level_config_id(&result).is_none()); + } + #[test] fn resolve_prefers_stable_over_unstable() { let result = serde_json::json!({ diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed8..5244ef5537 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -124,6 +124,11 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, + /// Auto mode — fully autonomous execution; model-gated (requires a model + /// that supports `supportsAutoMode`). Degrades gracefully to `default` + /// when the session's active model does not support it. + #[value(alias = "auto")] + Auto, /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, @@ -144,6 +149,7 @@ impl PermissionMode { pub fn as_wire_str(&self) -> &'static str { match self { Self::Default => "default", + Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", @@ -423,6 +429,14 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MODEL")] pub model: Option, + /// Persisted effort level value (e.g. "high", "medium", "low") to apply via + /// `session/set_config_option` at the first session creation. The configId is + /// resolved from the adapter's advertised `thought_level` capability — not + /// hardcoded. Non-fatal: if the adapter does not advertise `thought_level`, + /// the value is silently ignored and the persisted effort is not overwritten. + #[arg(long, env = "BUZZ_ACP_EFFORT_LEVEL")] + pub effort_level: Option, + /// Title for the agent's ACP sessions, passed out-of-band in `session/new` /// `_meta`. Adapters that recognize it name the session after this value; /// others ignore it. Never enters the prompt. @@ -540,6 +554,12 @@ pub struct Config { pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. pub model: Option, + /// Persisted effort level value (e.g. "high", "medium", "low"). Held as a + /// per-worker spawn-scoped value and applied at the first session creation + /// by pairing with the adapter's advertised `thought_level` configId. + /// Non-fatal when absent or when the adapter does not advertise + /// `thought_level`. + pub effort_level: Option, /// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`. /// `None` when unset or when the configured value sanitized to empty. pub session_title: Option, @@ -1105,6 +1125,7 @@ impl Config { typing_enabled: !args.no_typing, memory_enabled: args.memory && !args.no_memory, model, + effort_level: args.effort_level, session_title: args .session_title .as_deref() @@ -1480,6 +1501,7 @@ mod tests { typing_enabled: true, memory_enabled: true, model: None, + effort_level: None, session_title: None, permission_mode: PermissionMode::BypassPermissions, respond_to: RespondTo::Anyone, @@ -2298,6 +2320,7 @@ channels = "ALL" #[test] fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); + assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); assert_eq!( PermissionMode::BypassPermissions.as_wire_str(), @@ -2310,12 +2333,24 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); + assert!(!PermissionMode::Auto.is_default()); assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); } + #[test] + fn test_permission_mode_auto_degrades_to_default_when_unsupported() { + // The wire string is "auto" — the adapter handles graceful downgrade + // to "default" when the active model does not support Auto mode. + // Verify only that the wire string is correct and distinct from "default". + let auto = PermissionMode::Auto; + assert_eq!(auto.as_wire_str(), "auto"); + assert_ne!(auto.as_wire_str(), "default"); + assert!(!auto.is_default()); + } + #[test] fn test_permission_mode_display() { assert_eq!( @@ -2323,6 +2358,7 @@ channels = "ALL" "bypassPermissions" ); assert_eq!(format!("{}", PermissionMode::Default), "default"); + assert_eq!(format!("{}", PermissionMode::Auto), "auto"); } #[test] @@ -2360,6 +2396,7 @@ channels = "ALL" use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), + ("auto", PermissionMode::Auto), ("accept-edits", PermissionMode::AcceptEdits), ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), @@ -2382,6 +2419,7 @@ channels = "ALL" use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), + ("auto", PermissionMode::Auto), ("acceptEdits", PermissionMode::AcceptEdits), ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0f5cce3b8f..af00e0c7c6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1336,6 +1336,13 @@ fn handle_switch_model_control( tracing::warn!("observer switch_model control frame missing modelId"); return; }; + // Opaque per-pick correlator, echoed on every result frame so the Desktop + // can ignore a replayed result for an earlier pick. Optional: absent on + // older Desktop clients, in which case the frames simply carry no id. + let request_id = payload + .get("requestId") + .and_then(|value| value.as_str()) + .map(str::to_string); // 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 @@ -1352,7 +1359,10 @@ fn handle_switch_model_control( if signal_in_flight_task( pool, channel_id, - ControlSignal::SwitchModel(model_id.to_string()), + ControlSignal::SwitchModel { + model_id: model_id.to_string(), + request_id: request_id.clone(), + }, ) { "sent" } else { @@ -1360,7 +1370,7 @@ fn handle_switch_model_control( } } else { // Idle path: validate against the cached catalog before invalidating. - match pool.switch_idle_agent_model(channel_id, model_id) { + match pool.switch_idle_agent_model(channel_id, model_id, request_id.clone()) { IdleSwitchResult::Switched => "switched", IdleSwitchResult::UnsupportedModel => "unsupported_model", IdleSwitchResult::NoIdleAgent => "no_active_turn", @@ -1381,6 +1391,9 @@ fn handle_switch_model_control( "type": "switch_model", "status": status, "modelId": model_id, + // Echo the correlator on the immediate ack so a `sent` / + // `turn_ending` / idle-path terminal frame matches the pick. + "requestId": request_id, }), ); } @@ -2478,6 +2491,9 @@ async fn tokio_main() -> Result<()> { model_capabilities: None, desired_model: config.model.clone(), model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: config.effort_level.clone(), agent_name, goose_system_prompt_supported: None, protocol_version, @@ -4706,6 +4722,7 @@ struct PoolStartup { extra_env: Vec<(String, String)>, has_generated_codex_config: bool, model: Option, + effort_level: Option, observer: Option, } @@ -4718,6 +4735,7 @@ impl PoolStartup { extra_env: config.persona_env_vars.clone(), has_generated_codex_config: config.has_generated_codex_config, model: config.model.clone(), + effort_level: config.effort_level.clone(), observer, } } @@ -4785,6 +4803,9 @@ async fn initialize_agent_pool( model_capabilities: None, desired_model: startup.model.clone(), model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: startup.effort_level.clone(), agent_name, goose_system_prompt_supported: None, protocol_version, @@ -7144,6 +7165,7 @@ mod build_mcp_servers_tests { typing_enabled: true, memory_enabled: false, model: None, + effort_level: None, session_title: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, @@ -7367,6 +7389,7 @@ mod error_outcome_emission_tests { typing_enabled: true, memory_enabled: false, model: None, + effort_level: None, session_title: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, @@ -7413,6 +7436,9 @@ mod error_outcome_emission_tests { model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, // Error branches under test never read this; 1 is the legacy diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 5e1e960e0d..42492de4ae 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -30,9 +30,9 @@ use tokio::time::timeout; use uuid::Uuid; use crate::acp::{ - extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, - StopReason, SystemPromptTransport, + extract_model_config_options, extract_model_state, extract_thought_level_config_id, + model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, + ModelSwitchMethod, StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -88,6 +88,12 @@ pub struct AgentModelCapabilities { pub config_options_raw: Vec, /// Unstable: SessionModelState from session/new. pub available_models_raw: Option, + /// B5: configId for the `thought_level` category option, if the adapter + /// advertised one in session/new. Resolved at session time so the + /// spawn-scoped effort application forwards the adapter's real configId + /// instead of hardcoding it. `None` when the adapter advertises no + /// `thought_level` option. + pub thought_level_config_id: Option, } /// Successful deliveries associated with one live channel session. @@ -203,6 +209,28 @@ pub struct OwnedAgent { /// 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, + /// Opaque per-pick `request_id` from the live `SwitchModel` that set + /// `desired_model`, echoed on the late `control_result` frame so the + /// Desktop ModelPicker can correlate it to the pick that fired the switch. + /// `None` for config/persona-derived models (no live pick to correlate). + pub desired_model_request_id: Option, + /// True when a busy-path live switch is awaiting its deferred apply: the + /// switch was delivered to an in-flight turn (`sent` ack), the turn was + /// cancelled+requeued, and the real apply runs at the next session. On that + /// apply, `create_session_and_apply_model` emits a positive terminal + /// `control_result` (success) so the Desktop learns the outcome instead of + /// inferring it from timeout silence. The idle path never sets this — it + /// already emits its terminal immediately — so this gate prevents a + /// double-emit there. Consumed (reset) at apply time. + pub desired_model_pending_ack: bool, + /// Persisted startup effort value from `BUZZ_ACP_EFFORT_LEVEL` (carried from + /// the Desktop record via `Config.effort_level`). Held per-worker and applied + /// once, at the first session creation, by pairing with the adapter's + /// advertised `thought_level` configId. This is spawn-scoped only — there is + /// no pool-level effort state and no live mid-conversation effort switching. + /// Non-fatal when absent or when the adapter does not advertise + /// `thought_level`. + pub startup_effort: Option, /// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`). pub agent_name: String, /// Whether Goose accepted its custom system-prompt method. `None` probes on @@ -304,7 +332,7 @@ fn apply_completed_before_control_signal( // the fresh session applies the new model on its next creation. if matches!( control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ControlSignal::Rotate | ControlSignal::SwitchModel { .. } ) { state.invalidate(source); } @@ -312,7 +340,7 @@ fn apply_completed_before_control_signal( /// Control signal for an in-flight channel turn. /// -/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when +/// Not `Copy`: `SwitchModel` carries owned `String`s. Callers must clone when /// a value is needed after a move, or match by reference. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlSignal { @@ -335,7 +363,14 @@ pub enum ControlSignal { /// 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), + /// + /// Carries `(model_id, request_id)`: the opaque per-pick `request_id` + /// originates in the Desktop ModelPicker and is echoed on every + /// `control_result` frame so a replayed result cannot settle a later pick. + SwitchModel { + model_id: String, + request_id: Option, + }, } /// Goose-native non-cancelling steer request, sent from the main loop to an @@ -844,6 +879,7 @@ impl AgentPool { &mut self, channel_id: Uuid, model_id: &str, + request_id: Option, ) -> IdleSwitchResult { let Some(agent) = self .agents @@ -868,6 +904,9 @@ impl AgentPool { agent.desired_model = Some(model_id.to_string()); agent.model_overridden = true; + // Carry the pick's correlator so a deferred-validation miss on the next + // turn's session creation emits a late frame the Desktop can match. + agent.desired_model_request_id = request_id; agent.state.invalidate_channel(&channel_id); IdleSwitchResult::Switched } @@ -1046,17 +1085,94 @@ async fn create_session_and_apply_model( agent.model_capabilities = Some(AgentModelCapabilities { config_options_raw: extract_model_config_options(&resp.raw), available_models_raw: extract_model_state(&resp.raw), + thought_level_config_id: extract_thought_level_config_id(&resp.raw), }); } - // Apply desired_model if set, matching against the fresh session/new response. - // 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 { + // Apply desired_model if set, matching against the fresh session/new + // response. `post_switch_snapshot` drives everything downstream: + // `Some(value)` → a switch applied; `value` is the adapter's post-switch + // RPC response, whose `configOptions` describe the target + // model. Effort resolution and the Desktop capture both + // read it so they converge on the model the session is + // actually running, not the pre-switch default. + // `None` → no switch, or the adapter rejected/does-not-know the + // model; the session/new snapshot is cached as-is and + // `switch_succeeded` stays false. + let post_switch_snapshot: Option = if let Some(ref desired) = + agent.desired_model + { + // Consume the busy-path pending-ack once for this apply: only the + // `Applied` arm turns it into a positive terminal; the rejection and + // unsupported arms already emit their own correlated failure frame, so + // taking it here keeps a leftover flag from firing a spurious success + // on some later unrelated session. + let pending_ack = std::mem::take(&mut agent.desired_model_pending_ack); match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { - apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; - true + match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await? + { + ModelSwitchOutcome::Applied(switch_result) => { + // The adapter rebuilds `session.configOptions` for the + // target model and echoes them here. Refresh capabilities + // from that authoritative snapshot when present so the + // idle-switch guard and the panel reflect the target + // model; drop to `None` (re-derive next session) when the + // adapter returned no options so a pre-switch snapshot is + // never mistaken for the target model's. + if switch_result + .get("configOptions") + .is_some_and(|v| !v.is_null()) + { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: extract_model_config_options(&switch_result), + available_models_raw: extract_model_state(&switch_result), + thought_level_config_id: extract_thought_level_config_id( + &switch_result, + ), + }); + } else { + agent.model_capabilities = None; + } + // Busy-path deferred switch: emit a positive terminal so + // the Desktop confirms success from a real frame instead + // of inferring it from timeout silence. Gated on the + // pending-ack flag so the idle path (which already acked + // `switched` immediately) does not double-emit. + if pending_ack { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "switched", + "modelId": desired, + "requestId": agent.desired_model_request_id, + }), + ); + } + Some(switch_result) + } + ModelSwitchOutcome::Rejected => { + // The adapter explicitly rejected the switch: the session + // is still on its default model. Surface a terminal + // failure so the Desktop ModelPicker rejects the live pick + // instead of falsely reporting success, and preserve the + // pre-switch capabilities the session is really running. + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "failure", + "modelId": desired, + // Echo the pick's request_id so the Desktop can + // correlate this late frame to the operation + // that fired it, and ignore replayed results. + "requestId": agent.desired_model_request_id, + }), + ); + None + } + } } None => { tracing::warn!( @@ -1073,26 +1189,64 @@ async fn create_session_and_apply_model( "type": "switch_model", "status": "unsupported_model", "modelId": desired, + // Echo the pick's request_id (see the failure arm). + "requestId": agent.desired_model_request_id, }), ); - false + None } } } else { - false + None }; + let switch_succeeded = post_switch_snapshot.is_some(); + + // Apply the worker's spawn-scoped startup effort, if configured and the + // running model advertises a `thought_level` option. Runs on every session + // creation (config options are per-session), mirroring the model-switch + // application above. The held value comes from `BUZZ_ACP_EFFORT_LEVEL` and + // never mutates — there is no pool-level effort state and no live switching. + // Reads the post-switch snapshot so the configId is discovered on the model + // the session is actually running; computed BEFORE the capture emission so + // the cached configOptions tell the truth about the running session. + let effort_snapshot = post_switch_snapshot.as_ref().unwrap_or(&resp.raw); + let effort_outcome = apply_startup_effort(agent, effort_snapshot, &resp.session_id).await?; // 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. + // applied — false on the rejected/unsupported arms so the panel doesn't show + // a stale override badge. + // + // configOptions come from the post-switch snapshot on a successful switch + // (the target model's option set) and the session/new snapshot otherwise. + // Truthful capture: after a successful effort application the snapshot still + // carries the pre-set `currentValue`, so patch the applied option to the + // value the session is actually running. A rejected effort or a model with + // no `thought_level` option leaves the snapshot untouched. + let config_options_for_cache = { + let mut opts = effort_snapshot + .get("configOptions") + .cloned() + .unwrap_or(serde_json::Value::Null); + if let Some(StartupEffortOutcome::Applied { config_id, value }) = &effort_outcome { + patch_config_option_current_value(&mut opts, config_id, value); + } + opts + }; agent.acp.observe( "session_config_captured", serde_json::json!({ - "configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null), + "configOptions": config_options_for_cache, "modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null), - "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null), + // `models` must come from the SAME snapshot as configOptions — the + // post-switch snapshot on a successful switch, session/new otherwise. + // Taking it from `resp.raw` here would emit the target model's option + // set alongside the pre-switch model identity, so the desktop panel + // would report the old model as live after an applied switch. When a + // successful target response omits `models`, this emits Null rather + // than falling back to the pre-switch `resp.raw.models`. + "models": effort_snapshot.get("models").cloned().unwrap_or(serde_json::Value::Null), "modelOverridden": agent.model_overridden && switch_succeeded, // Pair identity for the desktop session-config cache, which is // keyed by (agent, relay) like the lifecycle frames. @@ -1141,18 +1295,35 @@ fn mcp_servers_with_git_origin( servers } +/// Outcome of a live model-switch RPC returned by [`apply_model_switch`]. +/// +/// `Applied` and `Rejected` are distinct outcomes and must not be collapsed: +/// the caller needs to know whether the session is now on the target model +/// before deciding what capabilities to cache and whether to surface a failure. +#[derive(Debug)] +enum ModelSwitchOutcome { + /// The adapter accepted the switch. Carries the RPC response value, which + /// may include refreshed `configOptions` for the target model. + Applied(serde_json::Value), + /// The adapter returned an application-level error (e.g. JSON error, + /// unrecognised model). The session is still on its default model; + /// pre-switch capabilities must be preserved. + Rejected, +} + /// Send the appropriate ACP model-switch request with a timeout. /// -/// On timeout or error, logs a warning and returns — the caller proceeds -/// with the agent's default model. This is intentionally non-fatal: a stale -/// response from a timed-out request is safely ignored by `read_until_response` -/// (non-matching JSON-RPC IDs are skipped). +/// Transport-class errors propagate as `Err` so the caller respawns the agent +/// rather than reuse a poisoned stdio stream. An application-level rejection is +/// non-fatal but distinct from success: it returns [`ModelSwitchOutcome::Rejected`] +/// so the caller preserves pre-switch capabilities and tells Desktop the pick +/// failed instead of silently claiming the switch landed. async fn apply_model_switch( acp: &mut AcpClient, session_id: &str, desired: &str, method: &ModelSwitchMethod, -) -> Result<(), AcpError> { +) -> Result { let method_label = match method { ModelSwitchMethod::ConfigOption { config_id, .. } => { format!("configOption (configId={config_id})") @@ -1177,11 +1348,15 @@ async fn apply_model_switch( .await; match result { - Ok(Ok(_)) => { + // Return the RPC result so the caller can consume the post-switch + // capability snapshot the adapter echoes (claude-agent-acp rebuilds + // `session.configOptions` on a model change and returns them here). + Ok(Ok(value)) => { tracing::info!( target: "pool::model", "applied model {desired} via {method_label} on session {session_id}" ); + Ok(ModelSwitchOutcome::Applied(value)) } // Transport-class errors may have corrupted the stdio stream — propagate // so the caller can respawn the agent instead of reusing a poisoned one. @@ -1194,14 +1369,18 @@ async fn apply_model_switch( target: "pool::model", "fatal error setting model {desired} via {method_label}: {e}" ); - return Err(e); + Err(e) } - // Application-level errors (Json, etc.) — agent is fine, just uses default model. + // Application-level errors (Json, etc.) — the adapter explicitly + // rejected the switch; the session is still on its default model. + // Distinct from a successful switch that returned no configOptions: + // the caller must preserve pre-switch capabilities here. Ok(Err(e)) => { tracing::warn!( target: "pool::model", "failed to set model {desired} via {method_label}: {e} — proceeding with agent default" ); + Ok(ModelSwitchOutcome::Rejected) } Err(_) => { // Outer timeout fired — the inner send_request may have left the @@ -1210,10 +1389,123 @@ async fn apply_model_switch( target: "pool::model", "model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal" ); - return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)); + Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)) + } + } +} + +/// Outcome of applying a worker's spawn-scoped startup effort at session creation. +/// +/// Drives truthful capture: only `Applied` patches the cached `currentValue`. +/// `Rejected` (adapter refused) and the `None` return (model advertises no +/// `thought_level` option, or no effort was configured) leave the session/new +/// snapshot untouched so the panel reflects the session's real state. +enum StartupEffortOutcome { + Applied { config_id: String, value: String }, + Rejected, +} + +/// Apply the worker's held `startup_effort` via `session/set_config_option`, if +/// set and the current model advertises a `thought_level` option. +/// +/// Returns `Ok(None)` when there is nothing to apply (no configured effort, or +/// the model has no `thought_level` option) or `Ok(Some(_))` describing whether +/// the adapter accepted the value. Transport-class errors propagate as `Err` so +/// the caller respawns the worker rather than reuse a poisoned stream — mirroring +/// [`apply_model_switch`]'s classification. Application-level rejection is +/// non-fatal: the session proceeds on the model's default effort. +async fn apply_startup_effort( + agent: &mut OwnedAgent, + session_new_result: &serde_json::Value, + session_id: &str, +) -> Result, AcpError> { + let Some(value) = agent.startup_effort.clone() else { + return Ok(None); + }; + let Some(config_id) = extract_thought_level_config_id(session_new_result) else { + tracing::info!( + target: "pool::effort", + "startup effort {value} configured but model advertises no thought_level option — leaving agent default" + ); + return Ok(None); + }; + + let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async { + agent + .acp + .session_set_config_option(session_id, &config_id, &value) + .await + }) + .await; + + match result { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::effort", + "applied startup effort {value} via configId={config_id} on session {session_id}" + ); + Ok(Some(StartupEffortOutcome::Applied { config_id, value })) + } + // Transport-class errors may have corrupted the stdio stream — propagate + // so the caller can respawn the agent instead of reusing a poisoned one. + Ok(Err(e @ AcpError::Io(_))) + | Ok(Err(e @ AcpError::WriteTimeout(_))) + | Ok(Err(e @ AcpError::Timeout(_))) + | Ok(Err(e @ AcpError::Protocol(_))) + | Ok(Err(e @ AcpError::AgentExited)) => { + tracing::error!( + target: "pool::effort", + "fatal error applying startup effort {value} via configId={config_id}: {e}" + ); + Err(e) + } + // Application-level rejection (e.g. Json) — agent is fine, uses default effort. + Ok(Err(e)) => { + tracing::warn!( + target: "pool::effort", + "adapter rejected startup effort {value} via configId={config_id}: {e} — proceeding with agent default" + ); + Ok(Some(StartupEffortOutcome::Rejected)) + } + Err(_) => { + // Outer timeout fired — the inner send_request may have left the + // stream in an unknown state. Treat as transport error. + tracing::error!( + target: "pool::effort", + "startup effort {value} via configId={config_id} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal" + ); + Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)) + } + } +} + +/// Patch the `currentValue` of the configOption whose `configId`/`id` matches +/// `config_id` in a session/new `configOptions` array, in place. +/// +/// Used by truthful capture: a successful `session/set_config_option` is not +/// reflected in the original session/new snapshot, so the accepted value is +/// written back before the snapshot is cached. A no-op when `options` is not an +/// array or no entry matches (the id came from the same array, so a match is +/// expected in practice). +fn patch_config_option_current_value( + options: &mut serde_json::Value, + config_id: &str, + value: &str, +) { + let Some(arr) = options.as_array_mut() else { + return; + }; + for opt in arr { + let matches = opt + .get("configId") + .or_else(|| opt.get("id")) + .and_then(|v| v.as_str()) + == Some(config_id); + if matches { + opt["currentValue"] = serde_json::Value::String(value.to_string()); + return; } } - Ok(()) } /// Set the session permission mode via `session/set_config_option`. @@ -2218,9 +2510,15 @@ pub async fn run_prompt_task( // `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 { + if let ControlSignal::SwitchModel { model_id, request_id } = &control_signal { agent.desired_model = Some(model_id.clone()); agent.model_overridden = true; + agent.desired_model_request_id = request_id.clone(); + // Busy path: the real apply is deferred to the requeued + // session. Arm the positive-terminal emit so that apply + // reports success explicitly rather than the Desktop + // inferring it from timeout silence. + agent.desired_model_pending_ack = true; } // Control signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. @@ -2311,7 +2609,7 @@ pub async fn run_prompt_task( // MUST send a PromptResult or the main loop deadlocks. if matches!( control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ControlSignal::Rotate | ControlSignal::SwitchModel { .. } ) { tracing::debug!( target: "pool::prompt", @@ -3693,7 +3991,7 @@ fn requeue_cancelled_batch( ) -> Option { let reason = match signal { ControlSignal::Steer => CancelReason::Steer, - ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt, + ControlSignal::Interrupt | ControlSignal::SwitchModel { .. } => CancelReason::Interrupt, // Cancel/Rotate discard the batch — no merged re-prompt. ControlSignal::Cancel | ControlSignal::Rotate => return None, }; @@ -4413,6 +4711,40 @@ mod tests { } } + // MINOR (#2884): the permission-mode RPC is gated on agent_supports_mode. + // An advertised mode issues set_config_option; an absent one is skipped so + // the harness falls back to per-tool auto-approval. Pin both edges directly. + #[test] + fn agent_supports_mode_advertised_auto_is_true() { + let session_new = json!({ + "modes": { "availableModes": [{ "id": "default" }, { "id": "auto" }] } + }); + assert!(agent_supports_mode( + &session_new, + PermissionMode::Auto.as_wire_str() + )); + } + + #[test] + fn agent_supports_mode_absent_auto_is_false() { + let session_new = json!({ + "modes": { "availableModes": [{ "id": "default" }] } + }); + assert!(!agent_supports_mode( + &session_new, + PermissionMode::Auto.as_wire_str() + )); + } + + #[test] + fn agent_supports_mode_missing_modes_field_is_false() { + let session_new = json!({ "sessionId": "sess-1" }); + assert!(!agent_supports_mode( + &session_new, + PermissionMode::Auto.as_wire_str() + )); + } + #[test] fn public_session_forwards_channel_origin_to_mcp() { let channel_id = Uuid::new_v4(); @@ -5653,6 +5985,9 @@ done"# model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -5747,6 +6082,9 @@ done"# model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -5919,6 +6257,9 @@ done"# model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -6069,6 +6410,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, @@ -6482,7 +6826,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, &PromptSource::Channel(ch_a), - &ControlSignal::SwitchModel("gpt-5".into()), + &ControlSignal::SwitchModel { + model_id: "gpt-5".into(), + request_id: None, + }, ); assert!(!s.has_channel_state(&ch_a)); @@ -6522,7 +6869,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" (ControlSignal::Steer, Some(CancelReason::Steer)), (ControlSignal::Interrupt, Some(CancelReason::Interrupt)), ( - ControlSignal::SwitchModel("gpt-5".into()), + ControlSignal::SwitchModel { + model_id: "gpt-5".into(), + request_id: None, + }, Some(CancelReason::Interrupt), ), (ControlSignal::Cancel, None), @@ -6638,7 +6988,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Case { name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason", error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), - signal: ControlSignal::SwitchModel("gpt-5".to_string()), + signal: ControlSignal::SwitchModel { + model_id: "gpt-5".to_string(), + request_id: None, + }, expected_outcome: "CancelDrainTimeout", batch_preserved: true, expected_reason: Some(CancelReason::Interrupt), @@ -7056,6 +7409,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, @@ -7114,6 +7470,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" model_capabilities: None, desired_model: None, model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, @@ -7549,7 +7908,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" ); } - fn make_prompt_context_no_owner() -> PromptContext { + pub(super) fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) } @@ -8141,3 +8500,805 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" server.abort(); } } + +#[cfg(test)] +mod startup_effort_tests { + use super::*; + use crate::acp::AcpClient; + use tests::make_prompt_context_no_owner; + + /// Build a protocol-v2, non-goose agent whose only ACP requests will be + /// `session/new` (id 0) then the startup-effort `session/set_config_option` + /// (id 1). `startup_effort` is the held spawn-scoped value under test. + fn effort_agent(acp: AcpClient, startup_effort: Option<&str>) -> OwnedAgent { + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: startup_effort.map(str::to_string), + agent_name: "effort-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + /// Spawn a scripted ACP that answers `session/new` (request #1) with the + /// given configOptions, then replies to the effort `set_config_option` + /// (request #2) with `effort_reply` (a JSON-RPC `result`/`error` body, minus + /// the id which is filled in). Any later request gets `{"ok":true}`. + async fn spawn_effort_acp(session_new_config_options: &str, effort_reply: &str) -> AcpClient { + let script = format!( + r#"count=0 +while IFS= read -r line; do + count=$((count + 1)) + id=$((count - 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_config_options}}}}}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{effort_reply}}}' + else + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}' + fi +done"# + ); + AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn effort ACP script") + } + + fn captured_config_options(obs: &observer::ObserverHandle) -> serde_json::Value { + obs.snapshot() + .into_iter() + .find(|e| e.kind == "session_config_captured") + .expect("session_config_captured emitted") + .payload["configOptions"] + .clone() + } + + fn effort_current_value(options: &serde_json::Value) -> Option { + options + .as_array()? + .iter() + .find(|o| o["category"] == "thought_level") + .and_then(|o| o["currentValue"].as_str()) + .map(str::to_string) + } + + const OPTS_WITH_EFFORT_DEFAULT_LOW: &str = r#"[{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]"#; + + #[tokio::test] + async fn test_applied_effort_patches_captured_current_value_to_high() { + let acp = spawn_effort_acp(OPTS_WITH_EFFORT_DEFAULT_LOW, r#""result":{"ok":true}"#).await; + let mut agent = effort_agent(acp, Some("high")); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let opts = captured_config_options(&obs); + assert_eq!( + effort_current_value(&opts).as_deref(), + Some("high"), + "applied effort must overwrite the pre-set currentValue in the capture" + ); + } + + #[tokio::test] + async fn test_rejected_effort_retains_captured_current_value() { + // Adapter answers the effort set with a JSON-RPC error → AgentError → + // application-level rejection: non-fatal, capture keeps the default. + let acp = spawn_effort_acp( + OPTS_WITH_EFFORT_DEFAULT_LOW, + r#""error":{"code":-32602,"message":"unsupported effort value"}"#, + ) + .await; + let mut agent = effort_agent(acp, Some("high")); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("rejection is non-fatal; session creation still succeeds"); + + let opts = captured_config_options(&obs); + assert_eq!( + effort_current_value(&opts).as_deref(), + Some("low"), + "a rejected effort must not falsify the capture — keep the running value" + ); + } + + #[tokio::test] + async fn test_no_thought_level_model_leaves_capture_unpatched() { + // Model advertises only a `model` option — no thought_level. The held + // effort is silently ignored and no set_config_option is sent. + let opts_no_effort = r#"[{"configId":"model","category":"model","currentValue":"m-a","options":[{"value":"m-a"}]}]"#; + let acp = spawn_effort_acp(opts_no_effort, r#""result":{"ok":true}"#).await; + let mut agent = effort_agent(acp, Some("high")); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let opts = captured_config_options(&obs); + assert_eq!( + opts, + serde_json::from_str::(opts_no_effort).unwrap(), + "no thought_level option → capture is the untouched session/new snapshot" + ); + } + + #[tokio::test] + async fn test_no_startup_effort_leaves_capture_unpatched() { + // No held effort at all: the set_config_option is never sent and the + // default currentValue survives into the capture. + let acp = spawn_effort_acp(OPTS_WITH_EFFORT_DEFAULT_LOW, r#""result":{"ok":true}"#).await; + let mut agent = effort_agent(acp, None); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let opts = captured_config_options(&obs); + assert_eq!( + effort_current_value(&opts).as_deref(), + Some("low"), + "with no configured effort the capture reflects the model default" + ); + } + + #[tokio::test] + async fn test_transport_error_on_effort_propagates_for_respawn() { + // Adapter exits after answering session/new but before the effort set → + // AgentExited (transport class) → Err so the caller respawns the worker + // instead of reusing a possibly-poisoned stream. + let script = format!( + r#"IFS= read -r _new +printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{OPTS_WITH_EFFORT_DEFAULT_LOW}}}}}' +IFS= read -r _effort +exit 0"# + ); + let acp = AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn transport-exit ACP script"); + let mut agent = effort_agent(acp, Some("high")); + + let ctx = make_prompt_context_no_owner(); + let err = create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect_err("transport-class effort failure must propagate as Err"); + assert!( + matches!(err, AcpError::AgentExited | AcpError::Io(_)), + "process exit mid-effort is a transport error, got {err:?}" + ); + } + + #[test] + fn test_patch_config_option_current_value_matches_by_id_key() { + // The `id` key (claude-agent-acp) must also match, not just `configId`. + let mut opts = serde_json::json!([ + { "id": "effort", "category": "thought_level", "currentValue": "low" } + ]); + patch_config_option_current_value(&mut opts, "effort", "high"); + assert_eq!(opts[0]["currentValue"], "high"); + } + + #[test] + fn test_patch_config_option_current_value_noop_on_non_array() { + let mut opts = serde_json::Value::Null; + patch_config_option_current_value(&mut opts, "effort", "high"); + assert!(opts.is_null(), "a null snapshot must stay null"); + } +} + +#[cfg(test)] +mod model_switch_tests { + use super::*; + use crate::acp::AcpClient; + use tests::make_prompt_context_no_owner; + + /// A protocol-v2 agent with a live `desired_model` override and no startup + /// effort. `model_overridden` is set so the capture's `modelOverridden` + /// reflects only whether the switch actually landed. + fn switching_agent(acp: AcpClient, desired_model: &str) -> OwnedAgent { + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: Some(desired_model.to_string()), + model_overridden: true, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "switch-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + /// Scripted ACP: `session/new` (request #1) returns `session_new_options`, + /// then the model-switch `set_config_option` (request #2) replies with + /// `switch_reply` (a JSON-RPC `result`/`error` body minus the id). Any later + /// request gets `{"ok":true}`. + async fn spawn_switch_acp(session_new_options: &str, switch_reply: &str) -> AcpClient { + let script = format!( + r#"count=0 +while IFS= read -r line; do + count=$((count + 1)) + id=$((count - 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_options}}}}}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}' + else + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}' + fi +done"# + ); + AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn switch ACP script") + } + + fn capture(obs: &observer::ObserverHandle) -> serde_json::Value { + obs.snapshot() + .into_iter() + .find(|e| e.kind == "session_config_captured") + .expect("session_config_captured emitted") + .payload + } + + fn control_results(obs: &observer::ObserverHandle) -> Vec { + obs.snapshot() + .into_iter() + .filter(|e| e.kind == "control_result") + .map(|e| e.payload) + .collect() + } + + // A `model`-category option offering the default model plus the target the + // agent wants to switch to. + const OPTS_MODEL_A_AND_B: &str = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + + #[tokio::test] + async fn test_applied_switch_refreshes_capabilities_from_post_switch_snapshot() { + // The adapter accepts the switch and echoes the target model's rebuilt + // configOptions — including a thought_level option the default model + // never advertised. Capabilities and the capture must reflect the target + // model, not the pre-switch default. + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"medium","options":[{"value":"low"},{"value":"medium"}]}]}"#; + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, switch_reply).await; + let mut agent = switching_agent(acp, "model-b"); + // Busy path: this switch was delivered to an in-flight turn and its apply + // is deferred to this requeued session. Arm the pending-ack and carry the + // pick's correlator so the Applied arm emits a correlated positive + // terminal instead of leaving the Desktop to infer success from silence. + agent.desired_model_pending_ack = true; + agent.desired_model_request_id = Some("req-busy-1".into()); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let caps = agent + .model_capabilities + .as_ref() + .expect("capabilities refreshed from the post-switch snapshot"); + assert_eq!( + caps.thought_level_config_id.as_deref(), + Some("effort"), + "the target model's thought_level option must be discovered post-switch" + ); + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], true, + "an applied switch must report modelOverridden true" + ); + assert!( + cap["configOptions"] + .as_array() + .is_some_and(|a| a.iter().any(|o| o["category"] == "thought_level")), + "the cached configOptions must be the target model's post-switch set" + ); + // The deferred apply must emit exactly one correlated positive terminal + // so the Desktop learns success from a real frame, not timeout silence. + let results = control_results(&obs); + assert_eq!( + results.len(), + 1, + "a busy-path applied switch emits exactly one positive terminal" + ); + assert_eq!(results[0]["status"], "switched"); + assert_eq!(results[0]["modelId"], "model-b"); + assert_eq!( + results[0]["requestId"], "req-busy-1", + "the positive terminal must carry the pick's correlator" + ); + assert!( + !agent.desired_model_pending_ack, + "the pending-ack is consumed once so it cannot re-fire on a later session" + ); + } + + #[tokio::test] + async fn test_rejected_switch_preserves_capabilities_and_emits_failure() { + // The adapter refuses the switch with a JSON-RPC error. The session is + // still on its default model: pre-switch capabilities survive, the + // capture reports modelOverridden false, and a terminal `failure` + // control_result tells Desktop the pick did not land. + let acp = spawn_switch_acp( + OPTS_MODEL_A_AND_B, + r#""error":{"code":-32602,"message":"model not accepted"}"#, + ) + .await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("an application-level rejection is non-fatal"); + + let caps = agent + .model_capabilities + .as_ref() + .expect("pre-switch capabilities must be preserved on rejection"); + assert!( + caps.config_options_raw + .iter() + .any(|o| o["currentValue"] == "model-a"), + "capabilities must still describe the default model the session runs" + ); + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], false, + "a rejected switch must not claim an override" + ); + let results = control_results(&obs); + assert_eq!(results.len(), 1, "exactly one control_result on rejection"); + assert_eq!(results[0]["status"], "failure"); + assert_eq!(results[0]["modelId"], "model-b"); + } + + #[tokio::test] + async fn test_busy_path_rejection_emits_only_failure_and_consumes_pending_ack() { + // K1 delayed-rejection at the Rust seam: a busy-path switch is armed + // (pending_ack), its apply is deferred to this requeued session, and the + // adapter then refuses it. The rejection arm must emit exactly one + // `failure` (no spurious positive `switched`) and consume the pending-ack + // so no later session can fire a phantom success. + let acp = spawn_switch_acp( + OPTS_MODEL_A_AND_B, + r#""error":{"code":-32602,"message":"model not accepted"}"#, + ) + .await; + let mut agent = switching_agent(acp, "model-b"); + agent.desired_model_pending_ack = true; + agent.desired_model_request_id = Some("req-busy-reject".into()); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("an application-level rejection is non-fatal"); + + let results = control_results(&obs); + assert_eq!( + results.len(), + 1, + "a busy-path rejection emits exactly one terminal — no phantom success" + ); + assert_eq!(results[0]["status"], "failure"); + assert_eq!(results[0]["requestId"], "req-busy-reject"); + assert!( + !agent.desired_model_pending_ack, + "the pending-ack is consumed even on rejection so it cannot re-fire" + ); + } + + #[tokio::test] + async fn test_applied_switch_without_options_drops_capabilities() { + // A successful switch whose response carries no configOptions (older + // adapter, or a model with no options): the pre-switch snapshot cannot + // be trusted for the target model, so capabilities drop to None to be + // re-derived on the next session — but the switch still counts as an + // override with no failure surfaced. + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{"ok":true}"#).await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + assert!( + agent.model_capabilities.is_none(), + "an optionless successful switch must drop stale capabilities" + ); + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], true, + "the switch still applied even with no echoed options" + ); + assert!( + control_results(&obs).is_empty(), + "a successful switch emits no failure control_result" + ); + } + + #[tokio::test] + async fn test_unsupported_model_emits_unsupported_without_switch_rpc() { + // The desired model is absent from the session/new catalog: no switch + // RPC is sent, the capture reports no override, and an + // `unsupported_model` control_result rejects the live pick. + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{"ok":true}"#).await; + let mut agent = switching_agent(acp, "model-z"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("an unresolvable model is non-fatal"); + + let cap = capture(&obs); + assert_eq!(cap["modelOverridden"], false); + let results = control_results(&obs); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["status"], "unsupported_model"); + assert_eq!(results[0]["modelId"], "model-z"); + } + + /// Scripted ACP whose `session/new` (request #1) returns a full result body + /// `session_new_result` (a JSON object minus the outer envelope), and whose + /// model-switch `set_config_option` (request #2) replies with `switch_reply` + /// (a JSON-RPC `result`/`error` body minus the id). Lets a test control the + /// `models` block in both the pre-switch and post-switch snapshots. + async fn spawn_switch_acp_full(session_new_result: &str, switch_reply: &str) -> AcpClient { + let script = format!( + r#"count=0 +while IFS= read -r line; do + count=$((count + 1)) + id=$((count - 1)) + if [ "$count" -eq 1 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{session_new_result}}}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}' + else + printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',"result":{{"ok":true}}}}' + fi +done"# + ); + AcpClient::spawn("bash", &["-c".to_string(), script], &[], false) + .await + .expect("spawn switch ACP script") + } + + /// F3: an applied switch must cache `models` from the POST-switch snapshot, + /// not the pre-switch `session/new` response. The pre-switch snapshot reports + /// the default model as current; the target response reports the target as + /// current. The emitted capture must carry the target's models block. The + /// Desktop-parsing half of this contract lives in `agent_config_tests.rs` + /// (`live_switch_models_from_post_switch_snapshot_parses_target_current`). + #[tokio::test] + async fn test_applied_switch_caches_target_model_not_pre_switch() { + // session/new: model-a is current. switch reply: model-b is current, + // and it echoes rebuilt configOptions so capabilities refresh cleanly. + let session_new = r#"{"sessionId":"sess-1","configOptions":[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-a","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#; + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-b","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#; + let acp = spawn_switch_acp_full(session_new, switch_reply).await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert_eq!( + cap["models"]["currentModelId"], "model-b", + "an applied switch must cache the target model, not the pre-switch model-a" + ); + } + + /// F3: an applied switch whose target response omits `models` must cache + /// Null — never fall back to the pre-switch `resp.raw.models`. Otherwise the + /// panel would report the pre-switch model as live after a successful switch. + #[tokio::test] + async fn test_applied_switch_without_models_does_not_leak_pre_switch_model() { + // session/new advertises model-a as current; the successful switch reply + // echoes configOptions (so the switch is Applied) but NO models block. + let session_new = r#"{"sessionId":"sess-1","configOptions":[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}],"models":{"currentModelId":"model-a","availableModels":[{"modelId":"model-a"},{"modelId":"model-b"}]}}"#; + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]}]}"#; + let acp = spawn_switch_acp_full(session_new, switch_reply).await; + let mut agent = switching_agent(acp, "model-b"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert!( + cap["models"].is_null(), + "an optionless-models successful switch must emit Null, not the pre-switch models" + ); + } + + /// Like `switching_agent` but also holds a spawn-scoped startup effort, so a + /// single session creation both switches the model AND applies startup + /// effort — the interaction F5.6 pins. + fn switching_agent_with_effort( + acp: AcpClient, + desired_model: &str, + startup_effort: &str, + ) -> OwnedAgent { + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: Some(desired_model.to_string()), + model_overridden: true, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: Some(startup_effort.to_string()), + agent_name: "switch-effort-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + fn effort_option_current_value(cap: &serde_json::Value) -> Option { + cap["configOptions"] + .as_array()? + .iter() + .find(|o| o["category"] == "thought_level") + .and_then(|o| o["currentValue"].as_str()) + .map(str::to_string) + } + + /// F5.6: startup effort resolves against the TARGET model's option set. The + /// pre-switch model-a advertises no `thought_level`; only the post-switch + /// model-b does. `apply_startup_effort` reads the post-switch snapshot, so + /// the held `high` applies against model-b's option and the cached + /// configOptions show it at `high`. Had it read the pre-switch snapshot the + /// effort would find no option and silently no-op. + #[tokio::test] + async fn test_startup_effort_resolves_against_post_switch_target_options() { + // session/new: model-a, model option only — NO thought_level. + let session_new = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + // switch reply: model-b current AND a target-only thought_level option. + let switch_reply = r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-b","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]}"#; + let acp = spawn_switch_acp(session_new, switch_reply).await; + let mut agent = switching_agent_with_effort(acp, "model-b", "high"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert_eq!( + effort_option_current_value(&cap).as_deref(), + Some("high"), + "startup effort must apply against the target model's thought_level option" + ); + } + + /// F5.6: an applied switch whose target response echoes NO options must not + /// apply the held startup effort against the STALE pre-switch options. The + /// pre-switch model-a advertised a `thought_level` option; the optionless + /// target response means the effort has no target option and must be + /// skipped — so the cached configOptions are Null, never the pre-switch + /// model-a options with a falsely patched `high`. + #[tokio::test] + async fn test_startup_effort_skips_stale_options_on_optionless_switch() { + // session/new: model-a WITH a thought_level option. + let session_new = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]},{"configId":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]"#; + // switch reply: applied, but NO echoed options. + let switch_reply = r#""result":{"ok":true}"#; + let acp = spawn_switch_acp(session_new, switch_reply).await; + let mut agent = switching_agent_with_effort(acp, "model-b", "high"); + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + + let ctx = make_prompt_context_no_owner(); + create_session_and_apply_model( + &mut agent, + &ctx, + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + id: None, + channel_type: None, + }, + ) + .await + .expect("session creation must succeed"); + + let cap = capture(&obs); + assert_eq!( + cap["modelOverridden"], true, + "the switch still applied even with no echoed options" + ); + assert!( + cap["configOptions"].is_null(), + "an optionless switch caches the target's (empty) options, never the pre-switch model-a options with a patched effort" + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs index badff621e8..5fc27ab905 100644 --- a/crates/buzz-backend-kubernetes/src/env.rs +++ b/crates/buzz-backend-kubernetes/src/env.rs @@ -389,6 +389,91 @@ mod tests { assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet"); } + /// F2 provider seam: the desktop strips both model keys from a Claude + /// launch.env and rides the canonical model on policy_env alone. This test + /// pins the final `build_env` output for that shape: the canonical + /// ANTHROPIC_MODEL survives (tier 1, no tier-2 key to overwrite it) and + /// BUZZ_ACP_MODEL is absent — so the remote process has exactly one model + /// authority. Same-value and conflicting-value collisions are both moot + /// because the desktop already removed the launch.env keys. + #[test] + fn claude_launch_yields_single_model_authority_through_build_env() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "claude", + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + // Desktop stripped both model keys from launch.env for claude. + "env": {"KEEP_ME": "yes"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!( + env["ANTHROPIC_MODEL"], "claude-opus-4", + "canonical model must survive as the single authority" + ); + assert!( + !env.contains_key("BUZZ_ACP_MODEL"), + "no second model authority may reach the remote process" + ); + assert_eq!(env["KEEP_ME"], "yes"); + } + + /// F2 provider seam, adversarial: even if a launch.env somehow still carries + /// model keys (older desktop, tampering), tier 2 later-wins over tier 1 — + /// which is exactly why the desktop must strip them. This documents the + /// hazard the desktop fix prevents: a launch.env ANTHROPIC_MODEL overrides + /// the canonical, and a launch.env BUZZ_ACP_MODEL introduces a second + /// authority. Neither key is authoritative in k8s, so the provider cannot + /// defend against it — the desktop strip is the only guard. + #[test] + fn launch_env_model_keys_would_win_over_policy_env_documenting_the_hazard() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "claude", + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + "env": {"ANTHROPIC_MODEL": "user-haiku", "BUZZ_ACP_MODEL": "user-sonnet"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!( + env["ANTHROPIC_MODEL"], "user-haiku", + "launch.env later-wins — proving the desktop must strip it" + ); + assert_eq!( + env["BUZZ_ACP_MODEL"], "user-sonnet", + "a leftover BUZZ_ACP_MODEL would be a second authority — desktop strips it" + ); + } + + /// F2 provider seam, same-value collision: a leftover launch.env + /// ANTHROPIC_MODEL that happens to match the canonical policy_env value is + /// still a second authority structurally — tier 2 later-wins, so the value + /// the remote process sees comes from launch.env, not the canonical tier. + /// It is only benign because the strings coincide; the desktop strip is what + /// guarantees the canonical tier is authoritative regardless of the leftover + /// value. Pinning the same-value case proves `build_env` cannot itself + /// distinguish a matching leftover from a conflicting one. + #[test] + fn launch_env_same_value_model_key_still_rides_tier_two_through_build_env() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "claude", + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + // Same value as the canonical policy_env entry. + "env": {"ANTHROPIC_MODEL": "claude-opus-4"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!( + env["ANTHROPIC_MODEL"], "claude-opus-4", + "value coincides, but it is tier 2 (launch.env) that wins — the \ + provider cannot tell a matching leftover from a conflicting one" + ); + } + /// `launch.env` already contains the merged user env, so re-merging the /// legacy field would undo a layering the desktop already resolved. #[test] diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 9da920483f..40c5464783 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -512,6 +512,196 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(id), host) } + #[tokio::test] + async fn archival_snapshot_advances_timestamp_for_rapid_state_replacement() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool.clone()).await else { + return; + }; + let tenant = seed_test_community(&pool).await; + let target_hex = Keys::generate().public_key().to_hex(); + let request_id = "a".repeat(64); + + state + .db + .archive( + tenant.community(), + &target_hex, + "self", + &target_hex, + None, + None, + &request_id, + ) + .await + .expect("archive identity"); + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish archived snapshot"); + let archived_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query archived snapshot") + .into_iter() + .next() + .expect("archived snapshot exists"); + + state + .db + .unarchive(tenant.community(), &target_hex) + .await + .expect("unarchive identity"); + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish unarchived snapshot"); + let final_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query final snapshot") + .into_iter() + .next() + .expect("final snapshot exists"); + + assert!( + final_snapshot.event.created_at > archived_snapshot.event.created_at, + "replacement snapshots must not rely on random same-second event-id ordering" + ); + assert!( + !final_snapshot.event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("p") + && fields.get(1).map(String::as_str) == Some(target_hex.as_str()) + }), + "final snapshot must reflect the canonical empty archive set" + ); + } + + /// Carl review 4954871389 test (b): a stale (pre-unarchive) publisher whose + /// canonical read predates the unarchive must not strand `target` in the + /// authoritative 13535. Deterministic via the `publish_test_hooks` barrier: + /// the stale publisher is held right after it reads `{target}`; the + /// unarchive and the compliant `{}` publish then run; only then is the stale + /// publisher released to attempt its write. Its post-insert + /// `snapshot_is_current` drift check sees canonical `{}` ≠ its `{target}` + /// snapshot, so it rebuilds and converges. RED-on-revert: replace that guard + /// with `let snapshot_is_current = true;` and the released stale publisher + /// commits `{target}` last, stranding the unarchived identity. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_archival_publishers_converge_on_canonical_state() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool.clone()).await else { + return; + }; + let tenant = seed_test_community(&pool).await; + let target_hex = Keys::generate().public_key().to_hex(); + let request_id = "b".repeat(64); + + // canonical -> {target} + state + .db + .archive( + tenant.community(), + &target_hex, + "self", + &target_hex, + None, + None, + &request_id, + ) + .await + .expect("archive identity"); + + // Arm the barrier, then spawn the stale publisher. It reads the + // `{target}` view, reaches the hook, and blocks until released. + let (reached_hook, release) = + crate::handlers::side_effects::publish_test_hooks::arm(tenant.community()); + let stale_tenant = tenant.clone(); + let stale_state = state.clone(); + let stale_publisher = + tokio::spawn( + async move { publish_nipia_archival_list(&stale_tenant, &stale_state).await }, + ); + // Deterministically wait until the stale publisher has read `{target}`. + reached_hook + .await + .expect("stale publisher reached the post-list_archived hook"); + + // canonical -> {} while the stale publisher holds its `{target}` view. + state + .db + .unarchive(tenant.community(), &target_hex) + .await + .expect("unarchive identity"); + // Production publishes after every archive-state mutation; do the same. + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish after unarchive"); + + // Release the stale publisher: it must detect drift and converge on `{}`. + release.notify_one(); + stale_publisher + .await + .expect("join stale publisher") + .expect("stale publisher converges without error"); + + let final_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query final snapshot") + .into_iter() + .next() + .expect("final snapshot exists"); + + assert!( + !final_snapshot.event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("p") + && fields.get(1).map(String::as_str) == Some(target_hex.as_str()) + }), + "a stale publisher must converge on the canonical empty set, never \ + strand the unarchived identity in the authoritative 13535" + ); + } + #[tokio::test] async fn owner_archive_rejects_stale_request_after_live_kind0_owner_flip() { let Some(pool) = test_pool().await else { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 282ea77657..f2b58937ab 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3116,6 +3116,61 @@ pub async fn reconcile_channel_events( Ok(()) } +/// Test-only barrier hooks for [`publish_nipia_archival_list`]. Lets a test +/// hold one publisher after it has read canonical archive state and before it +/// replaces the head, making the stale-read/late-write race deterministic. +/// Compiled only under `cfg(test)`; the production call site is `#[cfg(test)]`. +/// +/// The gate is scoped to a `CommunityId`: only a publisher whose tenant matches +/// the armed community is held. Publishers from other tenants — the rapid +/// archive/unarchive or owner-archive regressions running in parallel under the +/// Rust test runner — pass straight through and never consume the gate armed +/// for the concurrent-publisher test's unique tenant. +#[cfg(test)] +pub(crate) mod publish_test_hooks { + use buzz_core::tenant::CommunityId; + use std::sync::{Arc, Mutex}; + use tokio::sync::{oneshot, Notify}; + + struct Gate { + community: CommunityId, + arrived: oneshot::Sender<()>, + release: Arc, + } + + static GATE: Mutex> = Mutex::new(None); + + /// Arm a one-shot barrier for `community`. Await the returned receiver to + /// learn when the held publisher has reached the hook (i.e. has read + /// canonical state); call `notify_one` on the returned handle to let it + /// proceed. Only the first publisher of the matching community to reach the + /// hook after arming is held; every other publisher passes. + pub(crate) fn arm(community: CommunityId) -> (oneshot::Receiver<()>, Arc) { + let (tx, rx) = oneshot::channel(); + let release = Arc::new(Notify::new()); + *GATE.lock().unwrap() = Some(Gate { + community, + arrived: tx, + release: release.clone(), + }); + (rx, release) + } + + pub(super) async fn after_list_archived(community: CommunityId) { + let gate = { + let mut slot = GATE.lock().unwrap(); + match slot.as_ref() { + Some(gate) if gate.community == community => slot.take(), + _ => None, + } + }; + if let Some(gate) = gate { + let _ = gate.arrived.send(()); + gate.release.notified().await; + } + } +} + /// Publish a kind:13535 archived identities list event (NIP-IA). /// /// Queries all current archived identities and emits a relay-signed, @@ -3124,29 +3179,76 @@ pub async fn publish_nipia_archival_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let archived = state.db.list_archived(tenant.community()).await?; - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + const MAX_REPLACEMENT_ATTEMPTS: usize = 8; + let relay_pubkey = state.relay_keypair.public_key(); + let relay_pubkey_hex = relay_pubkey.to_hex(); - let mut tags: Vec = Vec::with_capacity(archived.len() + 1); - tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + // A concurrent archive mutation can race between reading the current head and + // replacing it. Rebuild from canonical state on rejection so an older snapshot + // can never strand the final archive set. + for _ in 0..MAX_REPLACEMENT_ATTEMPTS { + let archived = state.db.list_archived(tenant.community()).await?; + // Test-only barrier: lets a test hold a stale publisher here — after it + // has read canonical state, before it replaces the head — so the + // stale-read/late-write ordering the drift check must catch is + // deterministic, not scheduler-dependent. Inert in production. + #[cfg(test)] + publish_test_hooks::after_list_archived(tenant.community()).await; + let mut tags: Vec = Vec::with_capacity(archived.len() + 1); + tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); - for identity in &archived { - tags.push( - Tag::parse(["p", &identity.pubkey]) - .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, - ); - } + for identity in &archived { + tags.push( + Tag::parse(["p", &identity.pubkey]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + ); + } - let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; + // NIP-16 resolves same-second replacements by event id. Force this + // canonical snapshot strictly past the current head instead of letting a + // rapid archive→unarchive randomly preserve the stale archive state. + let now = nostr::Timestamp::now().as_secs(); + let previous = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(relay_pubkey.to_bytes().to_vec()), + limit: Some(1), + global_only: true, + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + let created_at = previous + .first() + .map(|event| (event.event.created_at.as_secs() + 1).max(now)) + .unwrap_or(now); + + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; + + let (stored, was_inserted) = state + .db + .replace_addressable_event(tenant.community(), &event, None) + .await?; + if !was_inserted { + continue; + } + + let current_archived = state.db.list_archived(tenant.community()).await?; + let snapshot_is_current = + archived + .iter() + .map(|identity| identity.pubkey.as_str()) + .eq(current_archived + .iter() + .map(|identity| identity.pubkey.as_str())); + if !snapshot_is_current { + continue; + } - let (stored, was_inserted) = state - .db - .replace_addressable_event(tenant.community(), &event, None) - .await?; - if was_inserted { dispatch_persistent_event( tenant, state, @@ -3156,13 +3258,16 @@ pub async fn publish_nipia_archival_list( None, ) .await; + info!( + archived_count = archived.len(), + "NIP-IA archived identities list published" + ); + return Ok(()); } - info!( - archived_count = archived.len(), - "NIP-IA archived identities list published" - ); - Ok(()) + anyhow::bail!( + "failed to publish kind:{KIND_IA_ARCHIVED_LIST} after {MAX_REPLACEMENT_ATTEMPTS} concurrent replacements" + ) } /// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 2dc0ba0d69..4df24e6e9b 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -13,9 +13,10 @@ use crate::{ }, }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, - known_acp_runtime, load_managed_agents, load_personas, save_managed_agents, - sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, - ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, + known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env, + save_managed_agents, sync_managed_agent_processes, AgentDefinition, BackendKind, + GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + MAX_ENV_VALUE_BYTES, }, }; @@ -121,6 +122,7 @@ fn resolve_config_surface( runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, + claude_config_dir: Option<&std::path::Path>, ) -> RuntimeConfigSurface { // Linked instances are definition-authoritative: clear stale materialized // model/provider/prompt so they can never masquerade as BuzzExplicit and @@ -138,7 +140,13 @@ fn resolve_config_surface( global, ); - read_config_surface(&record, runtime_meta, session_cache, &tiers) + read_config_surface( + &record, + runtime_meta, + session_cache, + &tiers, + claude_config_dir, + ) } /// Get the file-layer config for a runtime — used by the Create/Edit/Persona @@ -288,12 +296,36 @@ pub async fn get_agent_config_surface( let session_cache = state.get_session_cache(&runtime_key); let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + // #3493: for claude agents, resolve the settings.json and .claude.json paths + // from the agent's effective CLAUDE_CONFIG_DIR env var (if set), falling + // back to ~/.claude/ and ~/.claude.json. We never provision this dir + // ourselves — we only respect what the user configured. + // + // Use resolve_effective_agent_env so the lookup covers all tiers (baked + // floor → definition → global → persona → record) and cannot diverge from + // what the spawned process actually sees. + let claude_config_dir: Option = if runtime_meta + .is_some_and(|m| m.id == "claude") + { + let effective_env = resolve_effective_agent_env(&record, &personas, runtime_meta, &global); + // Treat empty or blank CLAUDE_CONFIG_DIR as unset, matching Claude's + // `CLAUDE_CONFIG_DIR || homedir()` resolver semantics. + effective_env + .env + .get("CLAUDE_CONFIG_DIR") + .filter(|v| !v.trim().is_empty()) + .map(std::path::PathBuf::from) + } else { + None + }; + Ok(resolve_config_surface( record, &personas, runtime_meta, session_cache.as_ref(), &global, + claude_config_dir.as_deref(), )) } @@ -503,6 +535,44 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } +/// Persist the canonical startup effort level for a local managed agent. +/// +/// B5 (v4 direct-write): the panel's EffortPicker calls this directly to set the +/// effort a spawn will apply at next session start. The value is stored on the +/// record; at spawn `runtime.rs` injects it as `BUZZ_ACP_EFFORT_LEVEL` and the +/// harness applies it via `session/set_config_option` against the adapter's +/// advertised `thought_level` configId. Pass `None` to clear (adapter default). +/// +/// Rejects non-local backends: remote agents receive effort through `policy_env` +/// at deploy time (see `agents_deploy.rs`), never this local persistence path — +/// so an effort edit against a deployed agent is a caller error, not a silent +/// no-op that leaves the panel and the running agent disagreeing. +#[tauri::command] +pub fn persist_agent_effort_level( + pubkey: String, + effort_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if record.backend != BackendKind::Local { + return Err(format!( + "agent {pubkey} is not a local agent; remote effort is set at deploy time" + )); + } + record.effort_level = effort_level; + record.updated_at = crate::util::now_iso(); + save_managed_agents(&app, &records) +} + #[cfg(test)] #[path = "agent_config_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 77e43f5d64..9c9aa58c1f 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -117,6 +117,7 @@ fn agent_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, @@ -182,6 +183,7 @@ fn linked_stale_record_model_never_outranks_persona_model() { Some(goose_runtime()), None, &Default::default(), + None, ); let model = surface.normalized.model.as_ref().expect("model resolved"); @@ -206,7 +208,14 @@ fn linked_blank_definition_model_falls_through_to_global_default() { ..Default::default() }; - let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &global, + None, + ); let model = surface.normalized.model.as_ref().expect("model resolved"); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -229,6 +238,7 @@ fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { Some(goose_runtime()), None, &Default::default(), + None, ); let model = surface.normalized.model.as_ref().expect("model resolved"); @@ -256,6 +266,7 @@ fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -284,6 +295,7 @@ fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -319,6 +331,7 @@ fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ) }); let model = surface.normalized.model.expect("model resolved"); @@ -347,6 +360,7 @@ fn persona_linked_live_switch_keeps_persona_default_secondary() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -382,6 +396,7 @@ fn global_default_live_switch_renders_global_model_as_secondary_global_default() Some(goose_runtime()), Some(&cache), &global, + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -666,3 +681,32 @@ fn baked_env_allowlist_is_case_insensitive() { // Unknown key → masked by default. assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); } + +/// F3 (Desktop-parsing half): the `models` block emitted by an applied live +/// switch — taken from the post-switch snapshot in `pool.rs` — must parse to the +/// target model as current. Pairs with the pool test +/// `test_applied_switch_caches_target_model_not_pre_switch`, which proves the +/// emitted block already carries `currentModelId=model-b`. +#[test] +fn live_switch_models_from_post_switch_snapshot_parses_target_current() { + let models = serde_json::json!({ + "currentModelId": "model-b", + "availableModels": [{"modelId": "model-a"}, {"modelId": "model-b"}], + }); + let (available, current) = parse_models(Some(&models)); + assert_eq!(current.as_deref(), Some("model-b")); + assert_eq!(available.len(), 2); +} + +/// F3 (Desktop-parsing half): a Null `models` block — emitted when a successful +/// switch's target response omits `models` — must parse to no current model, so +/// the pre-switch model is never revived in the cache. +#[test] +fn live_switch_null_models_parses_to_no_current_model() { + let (available, current) = parse_models(Some(&serde_json::Value::Null)); + assert!( + current.is_none(), + "Null models must not surface any current model" + ); + assert!(available.is_empty()); +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index d00969bf19..976519a076 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -9,6 +9,41 @@ use crate::{ const RELAY_DIRECTORY_PAGE_SIZE: usize = 500; const RELAY_FILTER_BATCH_SIZE: usize = 10; +/// Per-rebuild ceiling on directory-rebuild `/query` requests in flight at once. +/// The rebuild fans dozens of exact-author batches across the relay; issuing +/// them serially dominated agent-mention send latency (~6 s for ~100 +/// candidates). A bounded window collapses that to a few round trips while +/// keeping the request rate well under the relay's admission gate, which +/// back-pressures any 429 anyway. Each rebuild builds one semaphore and shares +/// it across every phase, so a single rebuild's runtime-directory and +/// owner-profile phases — which run concurrently under one `try_join!` — never +/// exceed it together. (Overlapping rebuilds each hold their own budget.) +const RELAY_DIRECTORY_MAX_CONCURRENCY: usize = 8; + +/// Run one `query_relay` request per `RELAY_FILTER_BATCH_SIZE` chunk of +/// `filters`, each acquiring a permit from `semaphore` so the total in-flight +/// request count stays within the shared ceiling even when several batch sets +/// run concurrently. Returned events are concatenated; order is unspecified — +/// every caller keys the events by pubkey downstream, so ordering is irrelevant. +async fn query_filter_batches( + state: &AppState, + semaphore: &tokio::sync::Semaphore, + filters: &[serde_json::Value], + error_label: &str, +) -> Result, String> { + let pages = futures_util::future::try_join_all(filters.chunks(RELAY_FILTER_BATCH_SIZE).map( + |batch| async move { + let _permit = semaphore.acquire().await.map_err(|error| { + format!("{error_label}: directory concurrency semaphore closed: {error}") + })?; + query_relay(state, batch) + .await + .map_err(|error| format!("{error_label}: {error}")) + }, + )) + .await?; + Ok(pages.into_iter().flatten().collect()) +} fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec { pubkeys @@ -129,24 +164,26 @@ async fn list_relay_agents_for_selection( return Ok(Vec::new()); } - let mut directory_events = Vec::new(); - let mut profile_events = Vec::new(); let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); let profile_filters = exact_author_filters(&candidate_pubkeys, 0); - for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) { - let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len()); - let (directory, profiles) = tokio::join!( - query_relay(state, &directory_filters[filter_offset..filter_end]), - query_relay(state, &profile_filters[filter_offset..filter_end]), - ); - directory_events.extend( - directory - .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?, - ); - profile_events.extend( - profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?, - ); - } + // One semaphore per rebuild caps `/query` requests across this rebuild's + // phases, so its runtime-directory and owner-profile phases below stay + // within the ceiling even though `try_join!` runs them concurrently. + let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); + let (directory_events, profile_events) = tokio::try_join!( + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current @@ -161,14 +198,13 @@ async fn list_relay_agents_for_selection( retain_verified_owner(&mut verified_owners, &viewer_pubkey); } let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); - let mut managed_agent_events = Vec::new(); - for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { - managed_agent_events.extend( - query_relay(state, filters) - .await - .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?, - ); - } + let managed_agent_events = query_filter_batches( + state, + &semaphore, + &managed_filters, + "relay agent managed-policy query failed", + ) + .await?; let mut agents = nostr_convert::relay_agents_from_directory_events( &directory_events, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index d1667a72bb..ed7a33d397 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -864,6 +864,7 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + effort_level: None, }; records.push(record); @@ -1238,12 +1239,8 @@ pub async fn delete_managed_agent( return Err(format!("agent {pubkey} not found")); } save_managed_agents(&app, &records)?; - // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); - // Tombstone-after-validation: only reached past the deployed-remote - // guard above and a confirmed removal — never orphan a live remote - // deployment's relay record. Inside the lock, before the block closes - // (no .await here). Every agent published, so every delete tombstones. + // Tombstone after confirmed removal (inside lock; every published agent tombstones). tombstone_managed_agent_pending(&app, &state, &pubkey); // NIP-IA: archive the deleted agent's identity on the relay so it // stops appearing in member pickers and autocomplete. Same diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 483eb60134..da5bb3ba5c 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -83,7 +83,25 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); } if let Some(value) = effective_model { - policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string()); + // B2: remote env-authority model key. Claude's startup model authority + // is ANTHROPIC_MODEL (same as the local A1 path — the harness reads it + // first and skips the BUZZ_ACP_MODEL catalog-switch path that would + // introduce a second startup authority). All other runtimes use + // BUZZ_ACP_MODEL, which the harness reads into desired_model at spawn. + let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); + let model_key = if is_claude { + "ANTHROPIC_MODEL" + } else { + "BUZZ_ACP_MODEL" + }; + policy_env.insert(model_key.into(), value.to_string()); + } + // I-4: remote parity for persisted startup effort. Mirrors the local spawn + // path in runtime.rs. The harness reads BUZZ_ACP_EFFORT_LEVEL into + // PoolStartup.startup_effort and applies it at first session creation via + // resolve_startup_effort(). + if let Some(ref value) = record.effort_level { + policy_env.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.clone()); } if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); @@ -101,10 +119,41 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } + // B5 remote parity: when a canonical effort_level is persisted, strip + // BUZZ_ACP_EFFORT_LEVEL from launch.env so it cannot shadow the canonical + // value in policy_env (tier 1). In the k8s three-tier model tier 2 + // (launch.env) overwrites tier 1 (policy_env) — later-wins — so the key + // must be absent from tier 2 whenever a canonical value is present. + // When effort_level is None there is no canonical to protect, so user + // env passthrough stands (env may legitimately seed startup effort). + // + // B2 remote parity: mirror the local A1 model authority. For a Claude + // launch, ALWAYS strip BOTH BUZZ_ACP_MODEL and ANTHROPIC_MODEL from + // launch.env — the resolved canonical model rides policy_env.ANTHROPIC_MODEL + // alone (set above), and launch.env later-wins over policy_env. Left in + // launch.env, a user BUZZ_ACP_MODEL would introduce a second startup + // authority and a user ANTHROPIC_MODEL would silently override the + // canonical model. When no canonical model is present, neither key is in + // policy_env, so stripping them keeps the remote process free of both — + // matching local, where `apply_claude_model_env(None)` removes both. + let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); + let strip_key = |k: &str| { + (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL")) + || (is_claude + && (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL") + || k.eq_ignore_ascii_case("ANTHROPIC_MODEL"))) + }; + let launch_env: BTreeMap = descriptor + .env + .iter() + .filter(|(k, _)| !strip_key(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + serde_json::json!({ "command": descriptor.command, "args": descriptor.args, - "env": descriptor.env, + "env": launch_env, "policy_env": policy_env, "owner_pubkey": owner_pubkey, }) @@ -284,13 +333,227 @@ mod tests { assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); + // goose runtime: model goes via BUZZ_ACP_MODEL (non-claude path). assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert!( + launch["policy_env"]["ANTHROPIC_MODEL"].is_null(), + "goose must NOT receive ANTHROPIC_MODEL" + ); assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + #[test] + fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() { + // B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL, + // so the remote harness has a single startup model authority matching A1. + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let teams: Vec = vec![]; + let launch = build_launch_block( + &record, + &descriptor, + &teams, + None, + Some("claude-opus-4"), + "owner-hex", + ); + assert_eq!( + launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4", + "claude remote must receive ANTHROPIC_MODEL" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_MODEL"].is_null(), + "claude remote must NOT receive BUZZ_ACP_MODEL" + ); + } + + /// F2: remote Claude launch must mirror local A1 — ALWAYS strip BOTH + /// BUZZ_ACP_MODEL and ANTHROPIC_MODEL from launch.env (tier 2), so the + /// canonical model in policy_env (tier 1) is the sole authority. Since + /// launch.env later-wins over policy_env, a user BUZZ_ACP_MODEL would add a + /// second startup authority and a user ANTHROPIC_MODEL would silently + /// override the canonical model. + #[test] + fn launch_block_claude_strips_both_model_keys_from_launch_env() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::from([ + ("BUZZ_ACP_MODEL".to_string(), "user-sonnet".to_string()), + ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()), + ("KEEP_ME".to_string(), "yes".to_string()), + ]), + }; + let launch = build_launch_block( + &record, + &descriptor, + &[], + None, + Some("claude-opus-4"), + "owner-hex", + ); + + // Canonical model rides policy_env alone. + assert_eq!(launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4"); + assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null()); + // Both model keys are stripped from launch.env — neither can later-win. + assert!( + launch["env"]["BUZZ_ACP_MODEL"].is_null(), + "user BUZZ_ACP_MODEL must be stripped from launch.env for claude" + ); + assert!( + launch["env"]["ANTHROPIC_MODEL"].is_null(), + "user ANTHROPIC_MODEL must be stripped from launch.env for claude" + ); + // Unrelated user env survives. + assert_eq!(launch["env"]["KEEP_ME"], "yes"); + } + + /// F2: when no canonical model resolves, a Claude launch still strips both + /// model keys from launch.env, so neither authority reaches the remote + /// process — matching local `apply_claude_model_env(None)`, which removes + /// both. + #[test] + fn launch_block_claude_strips_model_keys_even_without_canonical() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::from([ + ("BUZZ_ACP_MODEL".to_string(), "user-sonnet".to_string()), + ("ANTHROPIC_MODEL".to_string(), "user-opus".to_string()), + ]), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert!(launch["policy_env"]["ANTHROPIC_MODEL"].is_null()); + assert!(launch["policy_env"]["BUZZ_ACP_MODEL"].is_null()); + assert!( + launch["env"]["BUZZ_ACP_MODEL"].is_null(), + "user BUZZ_ACP_MODEL must be stripped even without a canonical model" + ); + assert!( + launch["env"]["ANTHROPIC_MODEL"].is_null(), + "user ANTHROPIC_MODEL must be stripped even without a canonical model" + ); + } + + /// F2: non-Claude runtimes must NOT strip model keys from launch.env — the + /// model authority stripping is Claude-specific (BUZZ_ACP_MODEL is the + /// spawn authority for other runtimes and rides policy_env there). + #[test] + fn launch_block_non_claude_preserves_user_model_env() { + let record = record(); // goose command + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_MODEL".to_string(), "user-model".to_string())]), + }; + let launch = + build_launch_block(&record, &descriptor, &[], None, Some("model"), "owner-hex"); + + // goose puts canonical in policy_env, and the user launch.env value is + // preserved (later-wins is the intended goose behavior). + assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert_eq!(launch["env"]["BUZZ_ACP_MODEL"], "user-model"); + } + + #[test] + fn launch_block_claude_runtime_injects_effort_level_when_set() { + // I-4: remote parity — record.effort_level → BUZZ_ACP_EFFORT_LEVEL in policy_env. + let mut record = record(); + record.effort_level = Some("high".to_string()); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "claude remote must receive BUZZ_ACP_EFFORT_LEVEL when effort_level is set" + ); + } + + #[test] + fn launch_block_does_not_inject_effort_level_when_absent() { + // I-4: no BUZZ_ACP_EFFORT_LEVEL in policy_env when record.effort_level is None. + let record = record(); // effort_level is None by default + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None" + ); + } + + /// B5 remote parity: when a canonical effort_level is persisted, a conflicting + /// user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env must NOT shadow it. + /// The canonical value in policy_env (tier 1) must win in the final build_env + /// output — the key must be absent from launch.env (tier 2) so tier 1 is + /// authoritative. + #[test] + fn launch_block_canonical_effort_strips_user_env_collision() { + let mut record = record(); + record.effort_level = Some("high".to_string()); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + // User-supplied conflicting value in descriptor.env. + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + // Canonical must be in policy_env (tier 1). + assert_eq!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], "high", + "canonical effort must be in policy_env when record.effort_level is Some" + ); + // Conflicting user value must be absent from launch.env (tier 2) so it + // cannot shadow the canonical tier-1 value in build_env. + assert!( + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "user BUZZ_ACP_EFFORT_LEVEL must be stripped from launch.env when canonical is present" + ); + } + + /// B5 remote parity: when no canonical effort is persisted (effort_level is + /// None), a user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env survives + /// into launch.env — passthrough preserved for startup seeding. + #[test] + fn launch_block_user_effort_env_survives_when_no_canonical_value() { + let record = record(); // effort_level is None + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]), + }; + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + // No canonical — key must NOT appear in policy_env. + assert!( + launch["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"].is_null(), + "policy_env must NOT contain BUZZ_ACP_EFFORT_LEVEL when effort_level is None" + ); + // User value must survive in launch.env so the harness can use it. + assert_eq!( + launch["env"]["BUZZ_ACP_EFFORT_LEVEL"], "low", + "user-supplied effort must survive in launch.env when no canonical value" + ); + } + /// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5" /// even when the record's requested parallelism is 10. This is the direct /// `launch.policy_env` seam test — the executable contract for remote providers. diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 0176478bc5..61a2d8a145 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -59,6 +59,7 @@ fn bare_agent_record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index d15ee82abc..0cc5679bf7 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -12,17 +12,50 @@ //! see §Owner-of-Agent Requests and §Relay Processing Algorithm. use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use crate::{ app_state::AppState, events, + managed_agents::try_regenerate_nest, relay::{ - classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override, - submit_event, SubmitEventResponse, + classify_request_error, query_relay, query_relay_at, relay_api_base_url, + relay_http_base_url, relay_ws_url, relay_ws_url_with_override, submit_event, + workspace_relay_override, SubmitEventResponse, }, }; +/// A relay target resolved from a single workspace-override read, so a caller +/// that performs several relay requests cannot mix two relays if the workspace +/// override changes mid-flight. +/// +/// `relay_ws_url_with_override` and `relay_api_base_url_with_override` each read +/// the override independently; a workspace switch between two such reads can +/// pair one relay's NIP-11 signer with another relay's snapshot query. +/// Capturing both fields from one read — matching those two functions' exact +/// precedence, including the standalone `BUZZ_RELAY_HTTP` path when no override +/// is set — guarantees the pair is internally consistent. +pub(crate) struct RelayTarget { + /// Relay WebSocket URL (drives the NIP-11 fetch and the rendered footer). + pub ws_url: String, + /// Relay HTTP API base URL (drives `/query`). + pub api_base_url: String, +} + +/// Capture the effective relay target once, before any network work. +pub(crate) fn capture_relay_target(state: &AppState) -> RelayTarget { + match workspace_relay_override(state) { + Some(url) => RelayTarget { + api_base_url: relay_http_base_url(&url), + ws_url: url, + }, + None => RelayTarget { + ws_url: relay_ws_url(), + api_base_url: relay_api_base_url(), + }, + } +} + // ── Helpers ───────────────────────────────────────────────────────────────── /// Read `target`'s live `kind:0` event and extract the first valid NIP-OA @@ -139,44 +172,116 @@ pub struct UnarchiveRequest { pub reason: Option, } -/// Submit a `kind:9035` archive request to the relay. Consent path is selected -/// by the relay — we just attach the owner-of-agent `auth` tag when the live -/// `kind:0` proves we own the target, so the relay can choose the `owner` -/// path. Self and admin paths require no auth tag. -#[tauri::command] -pub async fn archive_identity( - req: ArchiveRequest, - state: State<'_, AppState>, -) -> Result { - let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; - let auth_ref = auth_tag.as_ref(); +/// Roster refresh a successful archive/unarchive triggers. Binding the action +/// to a *type* rather than a closure selected at each call site is what closes +/// the regression Thufir found: the command wrapper passes a value (`&app`) +/// with no callback to construct, so the "regenerate on success" selection +/// lives entirely inside the cores below — where the tests traverse it. The +/// production binding is the single, irreducible `AppHandle` adapter. +pub(crate) trait NestRegenTrigger { + fn trigger(&self); +} +impl NestRegenTrigger for AppHandle { + fn trigger(&self) { + try_regenerate_nest(self); + } +} + +/// Submit `builder` to the active workspace relay, then trigger `on_success` +/// exactly once iff the relay accepted the event. +/// +/// This pins the shared half of the archive/unarchive → AGENTS.md-regeneration +/// contract: regeneration is best-effort roster maintenance, so it must fire on +/// a successful submission and must NOT fire when the submit is rejected (a +/// rejected request changed nothing to re-render). +async fn submit_then_regenerate( + builder: nostr::EventBuilder, + state: &AppState, + on_success: impl FnOnce(), +) -> Result { + let response = submit_event(builder, state).await?; + on_success(); + Ok(response) +} + +/// `AppHandle`-free core of [`archive_identity`]: resolve the owner-of-agent +/// `auth` tag, build the real `kind:9035` request, submit it, and trigger +/// `regen` so a successful archive refreshes the roster. +/// +/// The command wrapper is untestable (it needs a live Tauri runtime for its +/// `AppHandle`), so this core owns the whole orchestration — including *binding* +/// the regeneration trigger onto the successful-submit path. The wrapper only +/// hands it the `AppHandle` as the trigger; a test drives the exact archive +/// wiring with a counting trigger over a loopback relay. RED-on-revert: change +/// `|| regen.trigger()` to `|| {}` here and +/// `archive_core_fires_regen_only_on_accepted_submit` fails while the unarchive +/// core test stays green. +async fn archive_identity_core( + req: &ArchiveRequest, + state: &AppState, + regen: &impl NestRegenTrigger, +) -> Result { + let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?; let builder = events::build_archive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), req.replaced_by.as_deref(), - auth_ref, + auth_tag.as_ref(), )?; - submit_event(builder, &state).await + submit_then_regenerate(builder, state, || regen.trigger()).await } -/// Submit a `kind:9036` unarchive request to the relay. -#[tauri::command] -pub async fn unarchive_identity( - req: UnarchiveRequest, - state: State<'_, AppState>, +/// `AppHandle`-free core of [`unarchive_identity`]: builds the real `kind:9036` +/// request and triggers `regen` on acceptance. See [`archive_identity_core`] +/// for why this seam is extracted. RED-on-revert: change `|| regen.trigger()` +/// to `|| {}` here and `unarchive_core_fires_regen_only_on_accepted_submit` +/// fails while the archive core test stays green. +async fn unarchive_identity_core( + req: &UnarchiveRequest, + state: &AppState, + regen: &impl NestRegenTrigger, ) -> Result { - let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; - let auth_ref = auth_tag.as_ref(); - + let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?; let builder = events::build_unarchive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), - auth_ref, + auth_tag.as_ref(), )?; - submit_event(builder, &state).await + submit_then_regenerate(builder, state, || regen.trigger()).await +} + +/// Submit a `kind:9035` archive request to the relay. Consent path is selected +/// by the relay — we just attach the owner-of-agent `auth` tag when the live +/// `kind:0` proves we own the target, so the relay can choose the `owner` +/// path. Self and admin paths require no auth tag. +/// +/// On acceptance, refresh AGENTS.md so a just-archived agent drops from the +/// roster without waiting for the next unrelated edit or app restart. The +/// regen is fire-and-forget and fail-open like every other mutation site; it +/// races the relay's kind:13535 snapshot update, so a stale render self-heals +/// on the next regen. +#[tauri::command] +pub async fn archive_identity( + req: ArchiveRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + archive_identity_core(&req, &state, &app).await +} + +/// Submit a `kind:9036` unarchive request to the relay. See +/// [`archive_identity`]: refresh the roster so an unarchived agent reappears +/// promptly, fail-open against the same snapshot race. +#[tauri::command] +pub async fn unarchive_identity( + req: UnarchiveRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + unarchive_identity_core(&req, &state, &app).await } /// If the current user is the verified NIP-OA owner of `target`, return the @@ -228,8 +333,18 @@ struct RelayInformationDocument { } pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { - let relay_url = relay_ws_url_with_override(state); - let http_url = relay_http_base_url(&relay_url); + fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await +} + +/// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL +/// instead of re-resolving the workspace override. Used by +/// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot +/// query belong to the same captured relay target. +pub(crate) async fn fetch_relay_self_at( + state: &AppState, + relay_url: &str, +) -> Result, String> { + let http_url = relay_http_base_url(relay_url); let response = state .http_client .get(&http_url) @@ -275,46 +390,71 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec { .collect() } -/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend -/// caches this and tests membership client-side to drive the "Archived" flair. +/// Read the relay's latest valid `kind:13535` archive snapshot as lowercase +/// hex pubkeys. Shared by the `list_archived_identities` command (frontend +/// flair) and the backend nest regen (excluding archived agents from +/// `AGENTS.md`). /// /// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a /// snapshot signed by the relay identity advertised in NIP-11 `self` can affect -/// archive state. If the relay has no stable `self`, fail open with an empty -/// snapshot rather than trusting unauthenticated relay-authoritative state. -#[tauri::command] -pub async fn list_archived_identities( - state: State<'_, AppState>, -) -> Result { - let Some(relay_self) = fetch_relay_self(&state).await? else { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); +/// archive state. Every failure path — no stable `self`, no snapshot, a bad +/// signature or wrong author, or a query error — **fails open** with an empty +/// set rather than trusting unauthenticated relay-authoritative state. +pub(crate) async fn fetch_archived_pubkeys(state: &AppState) -> Vec { + fetch_archived_pubkeys_at(state, &capture_relay_target(state)).await +} + +/// Like [`fetch_archived_pubkeys`] but resolves both the NIP-11 signer and the +/// snapshot query against one captured [`RelayTarget`] instead of re-reading +/// the workspace override for each. This keeps a regeneration's advertised +/// signer and its snapshot query on the same relay even if the workspace +/// override changes between the two awaits. +pub(crate) async fn fetch_archived_pubkeys_at( + state: &AppState, + target: &RelayTarget, +) -> Vec { + let Ok(Some(relay_self)) = fetch_relay_self_at(state, &target.ws_url).await else { + return vec![]; }; - let events = query_relay( - &state, + let query = query_relay_at( + state, + &target.api_base_url, &[serde_json::json!({ "authors": [relay_self.clone()], "kinds": [13535], "limit": 1, })], ) - .await?; + .await; + let Ok(events) = query else { + return vec![]; + }; let Some(snapshot) = events.into_iter().next() else { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; }; // Defense-in-depth: the filter should already restrict author, but the // client must still reject malformed or wrongly signed relay state. if !snapshot.verify_id() || !snapshot.verify_signature() { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; } if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; } + archived_pubkeys_from_snapshot(&snapshot) +} + +/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend +/// caches this and tests membership client-side to drive the "Archived" flair. +#[tauri::command] +pub async fn list_archived_identities( + state: State<'_, AppState>, +) -> Result { Ok(ArchivedIdentitiesSnapshot { - archived: archived_pubkeys_from_snapshot(&snapshot), + archived: fetch_archived_pubkeys(&state).await, }) } @@ -336,6 +476,29 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[cfg(not(target_os = "windows"))] + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Counting [`NestRegenTrigger`] double: records how many times the core + /// fires regeneration on the successful-submit path, standing in for the + /// production `AppHandle` binding without a live Tauri runtime. + #[cfg(not(target_os = "windows"))] + #[derive(Default)] + struct CountingRegen(AtomicUsize); + + #[cfg(not(target_os = "windows"))] + impl CountingRegen { + fn count(&self) -> usize { + self.0.load(Ordering::SeqCst) + } + } + + #[cfg(not(target_os = "windows"))] + impl NestRegenTrigger for CountingRegen { + fn trigger(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } /// Build a fake `kind:0` with a valid NIP-OA auth tag for a fresh owner. fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event { @@ -478,4 +641,223 @@ mod tests { assert_eq!(minimal.content, ""); assert!(minimal.reason.is_none()); } + + /// Regression for the cross-relay capture defect: `fetch_archived_pubkeys_at` + /// must resolve BOTH the NIP-11 signer and the `/query` snapshot against the + /// single captured [`RelayTarget`], never re-reading the live workspace + /// override. Two loopback relays advertise distinct signers and archive + /// distinct pubkeys; we capture relay A, then mutate the override to relay B + /// before the fetch. Because capture happens once up front, the override's + /// value at any later instant — including between the two archive awaits — + /// is irrelevant by construction, so setting it to B is the strongest form + /// of that perturbation. A must supply both the signer and the snapshot. + /// + /// RED-on-revert: restore `fetch_archived_pubkeys` to read the override for + /// each leg (`fetch_relay_self` + `query_relay`) and this returns B's pubkey. + #[tokio::test] + async fn archived_fetch_never_crosses_relays_mid_flight() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + use axum::{routing::get, routing::post, Json, Router}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + // Build a loopback relay that advertises `relay_keys` as its NIP-11 + // `self` and serves a relay-signed 13535 snapshot archiving `archived`. + async fn spawn_relay(relay_keys: Keys, archived: String) -> String { + let self_hex = relay_keys.public_key().to_hex(); + let snapshot = EventBuilder::new(Kind::Custom(13535), "") + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["p", &archived]).unwrap(), + ]) + .sign_with_keys(&relay_keys) + .unwrap(); + let snapshot_json = serde_json::to_value(&snapshot).unwrap(); + + let router = Router::new() + .route( + "/", + get(move || { + let self_hex = self_hex.clone(); + async move { Json(serde_json::json!({ "self": self_hex })) } + }), + ) + .route( + "/query", + post(move || { + let snapshot_json = snapshot_json.clone(); + async move { Json(serde_json::json!([snapshot_json])) } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + format!("ws://{addr}") + } + + let relay_a_keys = Keys::generate(); + let relay_b_keys = Keys::generate(); + // Distinct archived pubkeys, unrelated to either relay's signing key — + // nostr 0.37's EventBuilder silently drops a `p` tag that references the + // event's own signer, so the archived key must not equal the relay key. + let archived_on_a = Keys::generate().public_key().to_hex(); + let archived_on_b = Keys::generate().public_key().to_hex(); + let relay_a = spawn_relay(relay_a_keys, archived_on_a.clone()).await; + let relay_b = spawn_relay(relay_b_keys, archived_on_b.clone()).await; + + let state = build_app_state(); + + // Capture relay A, then swap the override to relay B before the fetch. + *state.relay_url_override.lock().unwrap() = Some(relay_a.clone()); + let target = capture_relay_target(&state); + *state.relay_url_override.lock().unwrap() = Some(relay_b.clone()); + + let archived = fetch_archived_pubkeys_at(&state, &target).await; + + assert_eq!( + archived, + vec![archived_on_a], + "signer and snapshot must both come from the captured relay A, \ + never the mutated override (relay B)" + ); + reset_rate_limit_gate(); + } + + /// Spawn a loopback `/events` relay that answers every submit with the + /// given `accepted` verdict, so the archive/unarchive cores see a real + /// success or rejection over the wire. Returns the `ws://` base. + /// + /// The literal `/events` route below is why this file carries an + /// `EVENTS_INVENTORY` row (one occurrence, zero guard calls): a test + /// loopback, never a production egress site. + #[cfg(not(target_os = "windows"))] + async fn spawn_submit_relay(accepted: bool) -> String { + use axum::{routing::post, Json, Router}; + + let router = Router::new() + .route( + "/events", + post(move || async move { + Json(serde_json::json!({ + "event_id": "e".repeat(64), + "accepted": accepted, + "message": if accepted { "" } else { "rejected by relay" }, + })) + }), + ) + // The cores resolve the owner-of-agent auth tag first, which reads + // the target's live kind:0; answer with an empty result set so that + // read resolves to "no owner tag" without a live upstream relay. + .route("/query", post(|| async { Json(serde_json::json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + format!("ws://{addr}") + } + + /// Regression for the outsider-reported item 1, archive site: a successful + /// `kind:9035` archive MUST trigger nest regeneration, and a rejected + /// submit MUST NOT. This drives the production [`archive_identity_core`] + /// (the exact seam the command wrapper delegates to), forwarding a counting + /// hook against a loopback relay. RED-on-revert: replace the core's + /// forwarded `on_success` with `|| {}` and the "fires once" assertion fails; + /// this pins the archive command's callback independently of unarchive. + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn archive_core_fires_regen_only_on_accepted_submit() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + let state = build_app_state(); + let req = ArchiveRequest { + target_pubkey: Keys::generate().public_key().to_hex(), + content: String::new(), + reason: None, + replaced_by: None, + }; + + // Accepted archive → hook fires exactly once. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await); + let regen = CountingRegen::default(); + let response = archive_identity_core(&req, &state, ®en) + .await + .expect("accepted archive returns Ok"); + assert!(response.accepted); + assert_eq!( + regen.count(), + 1, + "an accepted archive must trigger regeneration exactly once" + ); + + // Rejected submit → error propagates, hook never fires. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await); + let regen = CountingRegen::default(); + let result = archive_identity_core(&req, &state, ®en).await; + assert!(result.is_err(), "a rejected archive must return an error"); + assert_eq!( + regen.count(), + 0, + "a rejected archive changed nothing, so regeneration must not fire" + ); + + reset_rate_limit_gate(); + } + + /// Regression for item 1, unarchive site: mirrors + /// [`archive_core_fires_regen_only_on_accepted_submit`] against the + /// `kind:9036` [`unarchive_identity_core`]. RED-on-revert: replace that + /// core's forwarded `on_success` with `|| {}` and this fails while the + /// archive test stays green — proving each command's callback is pinned + /// independently, not just the shared `submit_then_regenerate`. + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn unarchive_core_fires_regen_only_on_accepted_submit() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + let state = build_app_state(); + let req = UnarchiveRequest { + target_pubkey: Keys::generate().public_key().to_hex(), + content: String::new(), + reason: None, + }; + + // Accepted unarchive → hook fires exactly once. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await); + let regen = CountingRegen::default(); + let response = unarchive_identity_core(&req, &state, ®en) + .await + .expect("accepted unarchive returns Ok"); + assert!(response.accepted); + assert_eq!( + regen.count(), + 1, + "an accepted unarchive must trigger regeneration exactly once" + ); + + // Rejected submit → error propagates, hook never fires. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await); + let regen = CountingRegen::default(); + let result = unarchive_identity_core(&req, &state, ®en).await; + assert!(result.is_err(), "a rejected unarchive must return an error"); + assert_eq!( + regen.count(), + 0, + "a rejected unarchive changed nothing, so regeneration must not fire" + ); + + reset_rate_limit_gate(); + } } diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index f4590f5d6e..a4bbdeb677 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -67,6 +67,7 @@ fn make_agent( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index c052622215..fbfede3588 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -216,6 +216,7 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 6d7a2e6264..341426fe94 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -65,6 +65,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 7d3fd95ff3..75a1edea65 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -653,6 +653,7 @@ pub async fn confirm_agent_snapshot_import( definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + effort_level: None, runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 5e8cea52e6..fedb0e6058 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -74,6 +74,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 72bdca7de9..556127373b 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -59,6 +59,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 8315f39a36..e4c08a14be 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -610,6 +610,7 @@ pub async fn confirm_team_snapshot_import( definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + effort_level: None, runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index a466228160..bec7f43bf8 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -230,6 +230,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, runtime: None, name_pool: vec![], }; diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 1513742bea..0c2a9573af 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -276,6 +276,10 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Mock-relay route in its in-file tests; production publish goes through // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). ("src/commands/personas/sharing.rs", 1, 0), + // Loopback submit relay in `identity_archive.rs`'s in-file regen tests; + // production archive/unarchive publish through the guarded boundary-1 + // funnel via `submit_event`. + ("src/commands/identity_archive.rs", 1, 0), ]; // Needles are assembled at runtime so this scan file itself contains no diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cefdccfd69..31d4ff3713 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -57,17 +57,16 @@ use deep_link::{ take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks, PendingNavigationDeepLinks, }; -use huddle::audio_output::{ - get_audio_output_device, list_audio_output_devices, set_audio_output_device, -}; -use huddle::reconnect::reconnect_huddle_audio; use huddle::{ - add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, - download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, - get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle, - open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, - set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, - start_huddle, start_stt_pipeline, HuddlePhase, + add_agent_to_huddle, + audio_output::{get_audio_output_device, list_audio_output_devices, set_audio_output_device}, + check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, download_voice_models, + end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, + interrupt_huddle_speech, join_huddle, leave_huddle, open_huddle_window, push_audio_pcm, + reconnect::reconnect_huddle_audio, + remove_agent_from_huddle, set_huddle_manual_mic_unmuted, set_huddle_transcription_enabled, + set_tts_enabled, set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, + HuddlePhase, }; use initial_window::*; use managed_agents::{ @@ -786,6 +785,7 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, + persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index f70c714323..f0a4fabfed 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -223,6 +223,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 5b51c52255..4b734ce159 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -56,6 +56,13 @@ pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot"; /// this are stored as a URL reference instead. const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB +/// Maximum edge (px) for the PNG image body. The body is only a card +/// thumbnail — the manifest keeps the full-resolution source reference — so a +/// large avatar is downscaled here to keep the encoded snapshot well under +/// `MAX_SNAPSHOT_PNG_BYTES`. Mirrors the frontend SVG rasterizer's 512×512 cap +/// in `snapshotAvatarPng.ts`. +const MAX_PNG_BODY_EDGE: u32 = 512; + /// Format discriminator — used for sniffing and validation. pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot"; @@ -328,7 +335,7 @@ pub(crate) fn encode_chunk_payload_png( // there is no avatar or it cannot be decoded. let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { Some(bytes) => { - let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") && png_within_body_cap(bytes) { inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) }) @@ -449,6 +456,11 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result, S } /// Transcode a decodable avatar to PNG and add the snapshot manifest chunk. +/// +/// The decoded image is downscaled so its longest edge is at most +/// `MAX_PNG_BODY_EDGE` before PNG re-encoding. The body is only a card +/// thumbnail — this keeps a large source avatar (e.g. a 4K webp) from +/// producing a PNG that blows `MAX_SNAPSHOT_PNG_BYTES`. fn transcode_avatar_to_png_with_text( avatar_bytes: &[u8], keyword: &str, @@ -456,6 +468,7 @@ fn transcode_avatar_to_png_with_text( ) -> Result, String> { let image = image::load_from_memory(avatar_bytes) .map_err(|e| format!("Failed to decode avatar image: {e}"))?; + let image = downscale_to_body_cap(image); let mut png_bytes = Vec::new(); image .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) @@ -463,6 +476,32 @@ fn transcode_avatar_to_png_with_text( inject_text_chunk(&png_bytes, keyword, text) } +/// Downscale so the longest edge is at most `MAX_PNG_BODY_EDGE`, preserving +/// aspect ratio. Images already within the cap are returned untouched. +fn downscale_to_body_cap(image: image::DynamicImage) -> image::DynamicImage { + if image.width() <= MAX_PNG_BODY_EDGE && image.height() <= MAX_PNG_BODY_EDGE { + return image; + } + image.resize( + MAX_PNG_BODY_EDGE, + MAX_PNG_BODY_EDGE, + image::imageops::FilterType::Lanczos3, + ) +} + +/// Whether an already-PNG avatar is within the body dimension cap and can be +/// carried as-is (via a cheap tEXt-chunk injection) instead of being decoded +/// and downscaled. Undecodable headers fall through to the transcode path. +fn png_within_body_cap(png_bytes: &[u8]) -> bool { + Decoder::new(Cursor::new(png_bytes)) + .read_info() + .map(|reader| { + let info = reader.info(); + info.width <= MAX_PNG_BODY_EDGE && info.height <= MAX_PNG_BODY_EDGE + }) + .unwrap_or(false) +} + /// Inject a tEXt chunk into an existing PNG by re-encoding it. /// /// Re-decodes the image data via the `png` crate and writes a fresh PNG with diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 751452aa7b..de2f71577a 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -417,6 +417,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index fca15111d0..9f234749bc 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -73,6 +73,7 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + effort_level: None, } } @@ -233,7 +234,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { assert_eq!((reader.info().width, reader.info().height), (3, 2)); } -// ── PNG memory parity ───────────────────────────────────────────────────── +#[test] +fn png_snapshot_downscales_oversize_avatar_under_cap() { + // A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB) + // must be downscaled for the PNG body so the snapshot stays under the + // 10 MiB cap — while the manifest keeps the untouched source reference. + // An already-PNG oversize avatar exercises the `png_within_body_cap` guard + // that routes it through the downscaling transcode path. + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]) + })); + let mut source_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&source_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap(); + + assert!( + png_bytes.len() + <= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4, + "downscaled snapshot ({} bytes) must be far under the 10 MiB cap", + png_bytes.len() + ); + + let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap(); + let (width, height) = (reader.info().width, reader.info().height); + assert!( + width <= 512 && height <= 512, + "body dimensions {width}×{height} must fit the 512px cap" + ); + // Aspect ratio preserved: the longest edge (height) is clamped to the cap. + assert_eq!(height, 512, "longest edge should hit the 512px cap"); + + // The manifest keeps the untouched full-resolution source reference — only + // the PNG body is downscaled. The oversize source bytes exceed the inline + // cap, so the manifest falls back to the record's `avatar_url`. + let manifest = + decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap(); + assert_eq!( + manifest.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png"), + "manifest must preserve the untouched source avatar reference" + ); + assert!( + manifest.profile.avatar_data_url.is_none(), + "oversize source bytes must not be inlined into the manifest" + ); +} #[test] fn png_round_trip_with_core_memory() { diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs new file mode 100644 index 0000000000..647ea56209 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -0,0 +1,53 @@ +//! Claude Code agent spawn-time env helpers. +//! +//! A1 contract: `ANTHROPIC_MODEL` is the single startup model authority for +//! local Claude Code agents. `BUZZ_ACP_MODEL` is removed from the spawned +//! env so the harness never sees two model authorities simultaneously. +//! +//! B5 contract: `BUZZ_ACP_EFFORT_LEVEL` is the canonical persisted startup +//! effort authority for all local agents. Written after `descriptor.env` so +//! user-supplied entries cannot shadow a persisted canonical value. + +/// The spawn-time env var carrying startup effort. Shared by the spawn +/// application ([`apply_effort_env`]) and the snapshot projection +/// (`spawn_snapshot::effective_effort`) so the value the harness receives and +/// the value the restart badge compares are named from one place. +pub const EFFORT_LEVEL_ENV_VAR: &str = "BUZZ_ACP_EFFORT_LEVEL"; + +/// Apply the A1 model authority: inject `ANTHROPIC_MODEL` from `effective_model` +/// (or remove it if `None`) and strip `BUZZ_ACP_MODEL` from the spawned env. +/// +/// Must be called after `descriptor.env` is written so that any user-supplied +/// `ANTHROPIC_MODEL` is overridden by the Buzz-resolved value. +pub fn apply_claude_model_env(command: &mut std::process::Command, effective_model: Option<&str>) { + // Remove BUZZ_ACP_MODEL — the catalog-switch path is for live ACP switches + // only; at spawn time ANTHROPIC_MODEL is the sole authority. + command.env_remove("BUZZ_ACP_MODEL"); + match effective_model { + Some(m) => { + command.env("ANTHROPIC_MODEL", m); + } + None => { + command.env_remove("ANTHROPIC_MODEL"); + } + } +} + +/// Apply the B5 effort authority: inject `BUZZ_ACP_EFFORT_LEVEL` from +/// `effort_level` (or leave it untouched if `None`). +/// +/// Must be called after `descriptor.env` is written so the canonical persisted +/// value wins over any user-supplied `BUZZ_ACP_EFFORT_LEVEL` entry. When +/// `effort_level` is `None` there is no canonical value to assert; the command +/// env is left untouched so a user-supplied value from `descriptor.env` +/// legitimately seeds startup effort. +pub fn apply_effort_env(command: &mut std::process::Command, effort_level: Option<&str>) { + if let Some(e) = effort_level { + command.env(EFFORT_LEVEL_ENV_VAR, e); + } + // None: no canonical value — leave whatever descriptor.env wrote intact. +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs new file mode 100644 index 0000000000..f6f0f90cb2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -0,0 +1,127 @@ +use super::{apply_claude_model_env, apply_effort_env}; + +/// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after +/// `apply_claude_model_env`, even if it was set before (dual-authority defect). +/// ANTHROPIC_MODEL must be set to the resolved model. +#[test] +fn a1_buzz_acp_model_absent_anthropic_model_present_after_env_apply() { + let mut cmd = std::process::Command::new("true"); + // Simulate descriptor.env writing BUZZ_ACP_MODEL (the pre-A1 path). + cmd.env("BUZZ_ACP_MODEL", "claude-opus-4"); + apply_claude_model_env(&mut cmd, Some("claude-opus-4")); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + + // BUZZ_ACP_MODEL must be removed. Command::get_envs returns None for + // explicitly-removed keys. + let buzz_acp = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL")); + assert!( + buzz_acp.is_none() || buzz_acp.unwrap().is_none(), + "BUZZ_ACP_MODEL must be absent (or explicitly removed) after A1 policy" + ); + + // ANTHROPIC_MODEL must be set to the resolved model value. + let anthropic = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL")); + assert!(anthropic.is_some(), "ANTHROPIC_MODEL must be present"); + assert_eq!( + anthropic.unwrap().unwrap_or_default(), + "claude-opus-4", + "ANTHROPIC_MODEL must equal the effective model" + ); +} + +/// A1: when no model is resolved, ANTHROPIC_MODEL must be removed so Claude +/// uses its own default rather than inheriting a stale env value. +#[test] +fn a1_anthropic_model_removed_when_no_effective_model() { + let mut cmd = std::process::Command::new("true"); + // Pre-set a stale value that might have leaked in. + cmd.env("ANTHROPIC_MODEL", "claude-3-5-sonnet"); + cmd.env("BUZZ_ACP_MODEL", "claude-3-5-sonnet"); + apply_claude_model_env(&mut cmd, None); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + + let anthropic = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL")); + assert!( + anthropic.is_none() || anthropic.unwrap().is_none(), + "ANTHROPIC_MODEL must be absent when no effective model" + ); + let buzz_acp = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL")); + assert!( + buzz_acp.is_none() || buzz_acp.unwrap().is_none(), + "BUZZ_ACP_MODEL must always be absent after A1 policy" + ); +} + +// ── B5 effort-authority contract tests ────────────────────────────────────── +// +// These tests verify that `apply_effort_env`, called after `descriptor.env`, +// makes the canonical persisted effort win over any user-supplied value. + +/// B5 (local): canonical effort wins when user env supplies a conflicting value. +/// Simulates the defect scenario: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low, +/// then apply_effort_env is called with the canonical "high". The canonical value +/// must be what survives in the spawned-child env. +#[test] +fn b5_canonical_effort_wins_over_user_env_collision() { + let mut cmd = std::process::Command::new("true"); + // Simulate descriptor.env writing a user-supplied value (the pre-fix + // ordering: effort written before the loop, then loop overwrote it, or + // equivalently: effort written post-loop but with user value also post-loop). + cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); + + // Post-loop canonical application — the fix. + apply_effort_env(&mut cmd, Some("high")); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); + assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); + assert_eq!( + effort.unwrap().unwrap_or_default(), + "high", + "canonical effort must win over the user-supplied 'low' — B5 authority ordering" + ); +} + +/// B5 (local): when no canonical effort is persisted (effort_level is None), +/// user env passthrough is preserved — the descriptor.env entry seeds startup effort. +/// Simulates: descriptor.env wrote BUZZ_ACP_EFFORT_LEVEL=low (already in command), +/// then apply_effort_env(None) is called — user value must survive. +#[test] +fn b5_user_effort_env_survives_when_no_canonical_value() { + let mut cmd = std::process::Command::new("true"); + // Simulate descriptor.env loop having written a user-supplied value first. + cmd.env("BUZZ_ACP_EFFORT_LEVEL", "low"); + + // No canonical value — apply_effort_env(None) is a no-op so the user + // value already written by the descriptor.env loop survives intact. + apply_effort_env(&mut cmd, None); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); + assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); + assert_eq!( + effort.unwrap().unwrap_or_default(), + "low", + "user-supplied effort must survive when no canonical value is persisted" + ); +} + +/// B5 (local): canonical effort is present in the spawned env even when user +/// env did NOT supply a conflicting value (basic injection contract). +#[test] +fn b5_canonical_effort_injected_when_no_user_collision() { + let mut cmd = std::process::Command::new("true"); + // No user-supplied BUZZ_ACP_EFFORT_LEVEL in descriptor.env. + apply_effort_env(&mut cmd, Some("medium")); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + let effort = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_EFFORT_LEVEL")); + assert!(effort.is_some(), "BUZZ_ACP_EFFORT_LEVEL must be present"); + assert_eq!( + effort.unwrap().unwrap_or_default(), + "medium", + "canonical effort must be injected when no collision" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs index 449197a3b3..b54297df80 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -1,10 +1,28 @@ use super::types::{ExtensionEntry, RuntimeFileConfig}; -/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. -pub(super) fn read_config_file() -> Option { +/// Read Claude Code config from `settings.json` and `.claude.json`. +/// +/// `config_dir` — when `Some`, reads both `settings.json` and `.claude.json` +/// from that directory (the agent's effective `CLAUDE_CONFIG_DIR`). +/// Defaults to `~/.claude/settings.json` and `~/.claude.json` when `None`. +/// +/// Both files are resolved from the same directory: the claude 2.1.x binary +/// resolves `.claude.json` as `join(process.env.CLAUDE_CONFIG_DIR || homedir(), +/// ".claude.json")`, mirroring the `settings.json` resolver. A user-set +/// `CLAUDE_CONFIG_DIR` therefore remaps both files — honoring only +/// `settings.json` would misrepresent the agent's actual MCP config. +pub(super) fn read_config_file(config_dir: Option<&std::path::Path>) -> Option { let home = dirs::home_dir()?; - let settings_path = home.join(".claude").join("settings.json"); - let mcp_path = home.join(".claude.json"); + + // #3493: honor user-set CLAUDE_CONFIG_DIR for both settings.json and + // .claude.json — the binary resolves both relative to CLAUDE_CONFIG_DIR. + // Panel reflects the actual config the agent reads. + let settings_path = config_dir + .map(|d| d.join("settings.json")) + .unwrap_or_else(|| home.join(".claude").join("settings.json")); + let mcp_path = config_dir + .map(|d| d.join(".claude.json")) + .unwrap_or_else(|| home.join(".claude.json")); let settings = read_json_file(&settings_path); let mcp_config = read_json_file(&mcp_path); @@ -74,6 +92,22 @@ mod tests { } } + /// #3493: read_config_file(Some(dir)) must read settings.json from the + /// custom dir, not ~/.claude/settings.json — proves CLAUDE_CONFIG_DIR + /// actually remaps the settings read (not just the reported MCP path). + #[test] + fn reads_settings_from_custom_config_dir() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let mut f = std::fs::File::create(dir.path().join("settings.json")).unwrap(); + f.write_all(br#"{"model": "claude-opus-4", "effortLevel": "high"}"#) + .unwrap(); + + let cfg = read_config_file(Some(dir.path())).expect("settings.json in custom dir is read"); + assert_eq!(cfg.model.as_deref(), Some("claude-opus-4")); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + } + #[test] fn parse_model_from_settings() { let cfg = parse_settings(r#"{"model": "claude-sonnet-4-20250514"}"#); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index c51f325cf3..93827635e9 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -9,11 +9,16 @@ use super::types::*; /// persona and global tiers assembled at the command boundary. Each field /// builder constructs its own candidate list and resolves via /// `resolve_with_override`. +/// +/// `claude_config_dir` — when `Some`, the panel reads claude `settings.json` +/// and `.claude.json` from that directory (the agent's effective +/// `CLAUDE_CONFIG_DIR`) instead of `~/.claude/`. Ignored for non-claude runtimes. pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, + claude_config_dir: Option<&std::path::Path>, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -22,7 +27,7 @@ pub(crate) fn read_config_surface( .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)), + "claude" => super::claude::read_config_file(claude_config_dir).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, @@ -49,7 +54,14 @@ pub(crate) fn read_config_surface( .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")); + + // B5: the adapter-advertised effort control, selected ONCE by its category. + // The adapter defines it as category `thought_level` with its own config id + // (Claude Code emits `id="effort"`); reading by the literal category `effort` + // would miss it entirely. The running value, the write config id, and the + // picker options all derive from this single entry. + let effort_option = session_cache.and_then(find_effort_option); + let acp_effort = effort_option.and_then(|o| o.current_value.clone()); let model_overridden = session_cache.is_some_and(|c| c.model_overridden); @@ -79,9 +91,9 @@ pub(crate) fn read_config_surface( record, &file_config.thinking_effort, &acp_effort, + effort_option.map(|o| o.config_id.as_str()), thinking_env_var, is_pre_spawn, - session_cache, tiers, ), max_output_tokens: build_numeric_env_field( @@ -145,10 +157,9 @@ pub(crate) fn read_config_surface( }); } - let config_file_path = runtime_meta - .and_then(|m| m.config_file_path) - .map(resolve_tilde); - let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime); + let config_file_path = config_file_path_for_runtime(runtime_meta, claude_config_dir); + let mcp_config_file_path = + runtime_meta.and_then(|m| mcp_config_file_path_for_runtime(m, claude_config_dir)); let extensions = file_config.extensions.clone(); let sources = ConfigSourceReport { @@ -181,6 +192,12 @@ pub(crate) fn read_config_surface( mcp_config_file_path, }; + // B5: the adapter-advertised effort control, discovered once above. The UI + // uses `effort_config_id` to send `set_config_option` and renders + // `effort_options` instead of hardcoded values (never hardcoded here). + let effort_config_id = effort_option.map(|o| o.config_id.clone()); + let effort_options = effort_option.map(|o| o.options.clone()).unwrap_or_default(); + RuntimeConfigSurface { runtime_id: runtime_meta.map(|m| m.id.to_string()), runtime_label: runtime_meta.map(|m| m.label.to_string()), @@ -189,15 +206,52 @@ pub(crate) fn read_config_surface( advanced, extensions, sources, + claude_config_dir_custom: claude_config_dir.is_some(), + effort_config_id, + effort_options, } } -fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option { +/// Resolve the reported `settings.json` path. #3493: for a claude agent with a +/// custom `CLAUDE_CONFIG_DIR`, the reader reads `/settings.json`, so the +/// reported path must point there — not the static `~/.claude/settings.json` +/// from the runtime metadata. All other runtimes (and claude with no custom +/// dir) use the static metadata path. +fn config_file_path_for_runtime( + runtime_meta: Option<&KnownAcpRuntime>, + claude_config_dir: Option<&std::path::Path>, +) -> Option { + let runtime = runtime_meta?; + if runtime.id == "claude" { + if let Some(dir) = claude_config_dir { + return Some(dir.join("settings.json").to_string_lossy().into_owned()); + } + } + runtime.config_file_path.map(resolve_tilde) +} + +fn mcp_config_file_path_for_runtime( + runtime: &KnownAcpRuntime, + claude_config_dir: Option<&std::path::Path>, +) -> Option { match runtime.id { "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } - "claude" => Some(resolve_tilde("~/.claude.json")), + // #3493: the claude 2.1.x binary resolves .claude.json as + // join(CLAUDE_CONFIG_DIR || homedir(), ".claude.json"), so the MCP + // config file moves with a user-set CLAUDE_CONFIG_DIR. + "claude" => Some( + claude_config_dir + .map(|d| d.join(".claude.json")) + .unwrap_or_else(|| { + dirs::home_dir() + .map(|h| h.join(".claude.json")) + .unwrap_or_default() + }) + .to_string_lossy() + .into_owned(), + ), "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } @@ -486,12 +540,20 @@ fn build_thinking_field( record: &ManagedAgentRecord, file_effort: &Option, acp_effort: &Option, + effort_config_id: Option<&str>, thinking_env_var: Option<&str>, is_pre_spawn: bool, - session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: record env > ACP > persona env > global env > definition env > config file. + // Tier ordering: + // record env > record.effort_level (canonical Buzz-persisted) > ACP > + // persona env > global env > definition env > config file. + // + // `record.effort_level` is the B5 canonical value: the effort a spawn will + // actually apply at next session start (via `apply_effort_env`). Sitting it + // above ACP means the panel shows the *configured* value the agent will + // launch with rather than a stale live-session reading — the record can't + // be masked by, nor mask, the running value silently. let [rec_env, pers_env, glob_env, def_env] = thinking_env_var .map(|k| { env_candidates( @@ -504,8 +566,11 @@ fn build_thinking_field( }) .unwrap_or([None, None, None, None]); + let canonical_effort = record.effort_level.as_deref(); + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ (rec_env, ConfigOrigin::BuzzExplicit), + (canonical_effort, ConfigOrigin::BuzzExplicit), (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), (pers_env, ConfigOrigin::PersonaDefault), (glob_env, ConfigOrigin::GlobalDefault), @@ -514,16 +579,14 @@ fn build_thinking_field( ]; let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; - 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 { + let write_via = match (is_pre_spawn, effort_config_id, thinking_env_var) { + (false, Some(config_id), _) => ConfigWriteMechanism::AcpSetConfigOption { + config_id: config_id.to_string(), + }, + (_, _, Some(env_key)) => ConfigWriteMechanism::RespawnWithEnvVar { env_key: env_key.to_string(), - } - } else { - ConfigWriteMechanism::ReadOnly + }, + _ => ConfigWriteMechanism::ReadOnly, }; Some(NormalizedField { @@ -677,6 +740,19 @@ fn find_config_option_value(cache: &SessionConfigCache, category: &str) -> Optio .and_then(|o| o.current_value.clone()) } +/// Selects the adapter-advertised effort control from the session cache. +/// +/// The adapter emits effort under category `thought_level` with its own +/// config id (Claude Code uses `id="effort"`). Selecting by category — not by +/// a hardcoded id — is what lets the running value, the write config id, and +/// the picker options all derive from one entry. +fn find_effort_option(cache: &SessionConfigCache) -> Option<&AcpConfigOptionEntry> { + cache + .config_options + .iter() + .find(|o| o.category.as_deref() == Some("thought_level")) +} + fn has_config_option(cache: Option<&SessionConfigCache>, category: &str) -> bool { cache.is_some_and(|c| { c.config_options diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 0e7070724d..36b6022b53 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -116,6 +116,7 @@ fn test_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, @@ -168,7 +169,7 @@ fn persona_and_global_env_tiers( 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, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!(surface.is_pre_spawn); assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); @@ -184,7 +185,7 @@ fn surface_reports_mcp_specific_config_path() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(None, || { - read_config_surface(&record, Some(runtime), None, &no_tiers()) + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) }); let path = surface @@ -203,7 +204,7 @@ fn goose_mcp_config_path_follows_path_root_override() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { - read_config_surface(&record, Some(runtime), None, &no_tiers()) + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) }); let expected_path = Path::new("/tmp/buzz-goose-root") @@ -227,7 +228,7 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() { config_file_path: Some("~/.claude/settings.json"), ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!(surface .sources @@ -247,7 +248,7 @@ fn record_model_overrides_file_model() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -260,7 +261,7 @@ fn provider_locked_shows_locked() { provider_locked: true, ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); assert_eq!(provider.origin, ConfigOrigin::HarnessConstraint); @@ -286,7 +287,7 @@ fn post_spawn_with_model_config_option_uses_acp() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); assert!(!surface.is_pre_spawn); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("claude-opus-4")); @@ -310,7 +311,7 @@ fn acp_model_overrides_file_model_with_override_tracking() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("acp-model")); assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); @@ -331,7 +332,7 @@ fn persona_model_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("persona-model")); @@ -347,7 +348,7 @@ fn global_model_tier_produces_global_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -363,7 +364,7 @@ fn persona_provider_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("anthropic")); @@ -379,7 +380,7 @@ fn persona_prompt_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!( @@ -416,7 +417,7 @@ fn runtime_override_wins_display_when_model_overridden_is_true() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // Override wins the display value with a runtime-override origin. @@ -448,7 +449,7 @@ fn no_runtime_override_when_model_overridden_is_false() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // model_overridden is false => the override branch is not taken. @@ -480,7 +481,7 @@ fn no_false_positive_override_when_persona_edited_mid_life() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // model_overridden is false => no RuntimeOverride, even though @@ -539,7 +540,7 @@ fn explicit_record_model_not_retagged_when_already_present() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); @@ -562,7 +563,7 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { .insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -601,7 +602,7 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() { .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -662,7 +663,7 @@ fn buzz_agent_max_output_tokens_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -683,7 +684,7 @@ fn buzz_agent_context_limit_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("100000")); @@ -701,7 +702,7 @@ fn buzz_agent_max_tokens_absent_when_no_env_var_or_file() { let record = test_record(); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!( surface.normalized.max_output_tokens.is_none(), @@ -726,7 +727,7 @@ fn buzz_agent_max_tokens_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -747,7 +748,7 @@ fn buzz_agent_thinking_effort_from_env_is_buzz_explicit() { .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.thinking_effort.unwrap(); assert_eq!(field.value.as_deref(), Some("high")); @@ -768,7 +769,7 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -830,7 +831,7 @@ fn global_effort_surfaces_as_global_default_when_record_has_none() { let runtime = buzz_agent_rt(); let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -847,7 +848,7 @@ fn persona_effort_shadows_global_and_tags_persona_default() { let runtime = buzz_agent_rt(); let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -871,7 +872,7 @@ fn record_effort_outranks_persona_and_global_keeps_buzz_explicit() { let runtime = buzz_agent_rt(); let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -887,7 +888,7 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() { let record = test_record(); let runtime = buzz_agent_rt(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!( surface.normalized.thinking_effort.is_none(), @@ -897,6 +898,9 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() { /// AC-5 (conflicting-ACP): inherited effort set (global=high) + live ACP effort=low /// → ACP wins as primary (AcpConfigOption), global is the overridden secondary. +/// +/// The ACP entry uses the real adapter shape: category `thought_level` with an +/// adapter-defined config id (`effort`), NOT category `effort`. #[test] fn acp_effort_wins_over_inherited_global_effort_as_secondary() { let record = test_record(); @@ -904,7 +908,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() { let cache = SessionConfigCache { config_options: vec![AcpConfigOptionEntry { config_id: "effort".to_string(), - category: Some("effort".to_string()), + category: Some("thought_level".to_string()), display_name: Some("Effort".to_string()), current_value: Some("low".to_string()), options: vec![], @@ -918,7 +922,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() { }; let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let effort = surface .normalized @@ -942,7 +946,7 @@ fn numeric_max_tokens_inherits_from_global_env() { let runtime = buzz_agent_runtime(); let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("16384")); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 8613124f25..f86793f91a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -16,7 +16,7 @@ fn numeric_context_limit_inherits_from_persona_env() { let runtime = buzz_agent_runtime(); let tiers = persona_env_tiers("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("200000")); @@ -33,7 +33,7 @@ fn record_max_tokens_overrides_global_env_with_secondary() { let runtime = buzz_agent_runtime(); let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -64,7 +64,7 @@ fn global_env_prompt_wins_over_persona_structured_prompt() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!(prompt.value.as_deref(), Some("global-env-prompt")); @@ -87,7 +87,7 @@ fn persona_env_model_wins_over_persona_structured_model() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); // persona env outranks persona struct because env candidates precede struct @@ -106,7 +106,7 @@ fn structured_fallback_intact_when_no_env_representation() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("struct-persona-model")); @@ -130,7 +130,7 @@ fn post_sanitization_empty_global_env_falls_through_to_persona_tier() { // No global env (stripped); persona provides the valid fallback. let tiers = persona_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); // Persona value surfaces instead of the stripped global value. let effort = surface.normalized.thinking_effort.unwrap(); @@ -157,7 +157,7 @@ fn record_env_prompt_wins_over_record_struct_prompt_as_buzz_explicit() { ); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!(prompt.value.as_deref(), Some("env-prompt-B")); @@ -189,7 +189,7 @@ fn definition_env_beats_structured_persona_model() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("harness-model")); @@ -222,7 +222,7 @@ fn global_env_beats_definition_env() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -249,10 +249,272 @@ fn reserved_key_absent_from_definition_env_falls_through() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); // Falls through to persona structured model. assert_eq!(model.value.as_deref(), Some("persona-struct-model")); assert_eq!(model.origin, ConfigOrigin::PersonaDefault); } + +// ── B4/B5 canonical effort_level tier tests ──────────────────────────────── +// +// record.effort_level is the Buzz-canonical seeded value (the effort a spawn +// applies at next session start via `apply_effort_env`). It must surface as +// BuzzExplicit and take precedence over the config-file tier, but not over a +// record env var override. + +/// B4: record.effort_level surfaces as BuzzExplicit when no env var is set. +#[test] +fn b4_canonical_effort_level_surfaces_as_buzz_explicit() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from canonical record tier"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: record.effort_level shadows the config-file tier. +#[test] +fn b4_canonical_effort_level_shadows_file_tier() { + let mut record = test_record(); + record.effort_level = Some("medium".to_string()); + // No env var set — the config-file tier would win if canonical were absent. + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("canonical effort must shadow file tier"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: a record env var override still wins over record.effort_level, which +/// becomes the overridden baseline. +#[test] +fn b4_record_env_var_wins_over_canonical_effort_level() { + let mut record = test_record(); + record.effort_level = Some("low".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + let effort = surface + .normalized + .thinking_effort + .expect("env var must win over canonical effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!(effort.overridden_value.as_deref(), Some("low")); +} + +/// B4: None effort_level does not introduce a spurious tier. +#[test] +fn b4_none_canonical_effort_does_not_surface() { + let record = test_record(); // effort_level defaults to None + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + assert!( + surface.normalized.thinking_effort.is_none(), + "effort field must be absent when no tier has a value" + ); +} + +// ── CLAUDE_CONFIG_DIR path resolution (#3493) ───────────────────────────────── + +#[test] +fn claude_mcp_config_path_honors_custom_claude_config_dir() { + // #3493: mcp_config_file_path_for_runtime must use the custom dir when + // claude_config_dir is Some, not fall back to ~/.claude.json. + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let custom_dir = std::path::PathBuf::from("/custom/config/dir"); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), Some(&custom_dir)); + + let mcp_path = surface + .sources + .mcp_config_file_path + .expect("mcp_config_file_path must be present for claude runtime"); + assert_eq!( + std::path::Path::new(&mcp_path), + custom_dir.join(".claude.json"), + "mcp config path must be /.claude.json when CLAUDE_CONFIG_DIR is set" + ); + assert!( + surface.claude_config_dir_custom, + "claude_config_dir_custom must be true when a custom dir was passed" + ); +} + +#[test] +fn claude_config_dir_none_falls_back_to_home_claude_json() { + // #3493: None (i.e. the caller stripped an empty string) must resolve to + // the default ~/.claude.json path, matching Claude's `CLAUDE_CONFIG_DIR || homedir()`. + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + assert!( + !surface.claude_config_dir_custom, + "claude_config_dir_custom must be false when dir is None (unset)" + ); + assert!( + surface + .sources + .mcp_config_file_path + .as_deref() + .is_some_and(|p| p.ends_with(".claude.json")), + "mcp path must fall back to ~/.claude.json when no custom dir" + ); +} + +/// F1 regression: the effort control is selected by its `thought_level` category, +/// and the running value, the write config id, and the picker options all derive +/// from that single entry — even when the adapter's config id is a nonliteral +/// value and differs from the canonical (configured) effort. +/// +/// Live shape: `id="thinking-level", category="thought_level", currentValue="default"` +/// while canonical `record.effort_level=high`. Both facts must render: configured +/// `high` as the value and running `default` as the overridden secondary; the +/// write mechanism must carry the adapter's real id, never a hardcoded `"effort"`. +#[test] +fn effort_option_selected_by_category_drives_all_facts() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = buzz_agent_rt(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "thinking-level".to_string(), + category: Some("thought_level".to_string()), + display_name: Some("Thinking level".to_string()), + current_value: Some("default".to_string()), + options: vec![ + AcpConfigOptionValue { + value: "default".to_string(), + display_name: Some("Default".to_string()), + }, + AcpConfigOptionValue { + value: "high".to_string(), + display_name: Some("High".to_string()), + }, + ], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = InheritedConfigTiers::default(); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); + + // Two-facts display: configured `high` wins, running `default` is the secondary. + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface with both configured and running facts"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + assert_eq!(effort.overridden_value.as_deref(), Some("default")); + assert_eq!( + effort.overridden_origin, + Some(ConfigOrigin::AcpConfigOption) + ); + + // Write mechanism carries the adapter's real id, never a hardcoded "effort". + match &effort.write_via { + ConfigWriteMechanism::AcpSetConfigOption { config_id } => { + assert_eq!(config_id, "thinking-level"); + } + other => panic!("expected AcpSetConfigOption with adapter id, got {other:?}"), + } + + // Picker metadata derives from the same entry. + assert_eq!(surface.effort_config_id.as_deref(), Some("thinking-level")); + assert_eq!( + surface + .effort_options + .iter() + .map(|o| o.value.as_str()) + .collect::>(), + vec!["default", "high"], + ); +} + +// ── #3493: config_file_path follows a custom CLAUDE_CONFIG_DIR ───────────────── + +#[test] +fn claude_custom_config_dir_reports_isolated_settings_path() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let custom = std::path::Path::new("/tmp/iso-config"); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), Some(custom)); + + // The reported settings path is rooted at the custom dir the reader used, + // not the static ~/.claude/settings.json metadata. Compare as paths so the + // separator is native (Windows joins with `\`, not `/`). + assert_eq!( + surface + .sources + .config_file_path + .as_deref() + .map(std::path::Path::new), + Some(custom.join("settings.json").as_path()), + ); + // And the MCP file attribution follows the same custom root. + assert_eq!( + surface + .sources + .mcp_config_file_path + .as_deref() + .map(std::path::Path::new), + Some(custom.join(".claude.json").as_path()), + ); +} + +#[test] +fn claude_default_config_dir_reports_static_settings_path() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); + + // With no custom dir, the settings path resolves the static tilde metadata. + // Compare the trailing components as a path so the check is separator-native. + assert!(surface + .sources + .config_file_path + .as_deref() + .map(std::path::Path::new) + .is_some_and(|p| p.ends_with(".claude/settings.json"))); + assert!(surface + .sources + .config_file_path + .as_deref() + .is_some_and(|p| !p.starts_with('~'))); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 6ca2592538..d96736fb69 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -76,8 +76,21 @@ pub enum ConfigOrigin { } /// How a config field can be written back to the runtime. +/// +/// `rename_all_fields` is load-bearing, not decoration: on an internally +/// tagged enum `rename_all` renames the *variants*, never the variants' +/// fields, so without it `RespawnWithEnvVar` serializes as +/// `{"type":"respawnWithEnvVar","env_key":"…"}` while +/// `desktop/src/shared/api/types.ts` declares `envKey`. `invokeTauri` is an +/// unchecked cast, so `tsc` cannot see the mismatch — the reader just gets +/// `undefined`. `wire_format_matches_typescript_contract` below pins the exact +/// bytes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum ConfigWriteMechanism { /// Update record env vars, save, stop + restart agent. RespawnWithEnvVar { env_key: String }, @@ -175,6 +188,25 @@ pub struct RuntimeConfigSurface { pub advanced: Vec, pub extensions: Vec, pub sources: ConfigSourceReport, + /// #3493: `true` when the panel is reading from a user-set `CLAUDE_CONFIG_DIR` + /// rather than the default `~/.claude/`. Used to show the Keychain caveat + /// note in the panel: a custom config dir means a fresh Keychain namespace + /// (hash-suffixed), so the agent will be logged out unless the user also + /// manages `CLAUDE_SECURESTORAGE_CONFIG_DIR`. + #[serde(default)] + pub claude_config_dir_custom: bool, + /// B5: the real `configId` for the `thought_level` ACP config option, + /// as advertised by the adapter in `session/new`. Present only for claude + /// runtimes after the first session is created. The UI uses this to send + /// `set_config_option` without hardcoding the configId. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_config_id: Option, + /// B5/I-7: the adapter-advertised option values for the `thought_level` + /// config option. Present when `effort_config_id` is Some. The UI renders + /// these instead of hardcoded low/medium/high so model-specific option sets + /// are reflected correctly. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effort_options: Vec, } /// Raw config values extracted from a runtime's config file. @@ -244,3 +276,105 @@ pub struct AcpModelEntry { pub name: Option, pub description: Option, } + +#[cfg(test)] +mod wire_format_tests { + use super::*; + use serde_json::json; + + /// Every `ConfigWriteMechanism` variant, as `desktop/src/shared/api/types.ts` + /// declares it. Whole-value comparison, not a key-set check: a key-set + /// assertion still passes if the variant *name* regresses, and the `type` + /// discriminant is what every `switch (writeVia.type)` reads. Compared as + /// `serde_json::Value` rather than as text, because JSON object order is + /// not semantic and the contract is the keys and values, not the encoder's + /// field order. + #[test] + fn wire_format_matches_typescript_contract() { + let cases = [ + ( + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + }, + json!({"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}), + ), + ( + ConfigWriteMechanism::AcpSetConfigOption { + config_id: "model".into(), + }, + json!({"type": "acpSetConfigOption", "configId": "model"}), + ), + ( + ConfigWriteMechanism::AcpSetSessionModel, + json!({"type": "acpSetSessionModel"}), + ), + ( + ConfigWriteMechanism::GooseNativeConfigWrite { + config_key: "goose.model".into(), + }, + json!({"type": "gooseNativeConfigWrite", "configKey": "goose.model"}), + ), + (ConfigWriteMechanism::ReadOnly, json!({"type": "readOnly"})), + ]; + for (mechanism, expected) in cases { + assert_eq!( + serde_json::to_value(&mechanism).expect("serialize"), + expected + ); + } + } + + /// The renderer never sees a bare mechanism — it arrives nested inside + /// `NormalizedField`, which is where the mismatch used to hide: the + /// enclosing struct's `writeVia` / `overriddenValue` / `isRequired` all + /// renamed correctly, so only the variant's own field was snake_case. + #[test] + fn nested_field_is_camel_case_all_the_way_down() { + let field = NormalizedField { + value: Some("v".into()), + origin: ConfigOrigin::EnvVar, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + }, + overridden_value: Some("o".into()), + overridden_origin: Some(ConfigOrigin::ConfigFile), + is_required: true, + }; + assert_eq!( + serde_json::to_value(&field).expect("serialize"), + json!({ + "value": "v", + "origin": "envVar", + "writeVia": {"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}, + "overriddenValue": "o", + "overriddenOrigin": "configFile", + "isRequired": true, + }) + ); + } + + /// The contract is singular: the shape the renderer sends back round-trips, + /// and the old snake_case spelling is no longer accepted. Without the + /// second half, a future revert would still deserialize and the read path + /// would look healthy. + #[test] + fn camel_case_round_trips_and_snake_case_is_rejected() { + let parsed: ConfigWriteMechanism = + serde_json::from_str(r#"{"type":"respawnWithEnvVar","envKey":"GOOSE_MODE"}"#) + .expect("the TypeScript shape must deserialize"); + assert_eq!( + parsed, + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + } + ); + + assert!( + serde_json::from_str::( + r#"{"type":"respawnWithEnvVar","env_key":"GOOSE_MODE"}"# + ) + .is_err(), + "the pre-fix snake_case spelling must not be accepted" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index e53c9114ab..f7e233fbe9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -284,13 +284,13 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } #[test] fn record_agent_command_own_runtime_wins_over_persona() { - // A record with its own materialized runtime never consults the - // persona list — the unified-model resolution. + // A record with its own runtime never consults the persona list. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let record = record_with(Some("claude"), Some("p1"), None); assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index ee18e554c3..5b048b815c 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -89,6 +89,7 @@ fn record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index b2a56870c7..65cde47f26 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -349,6 +349,7 @@ fn bare_record() -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 16234aa3d6..272c03348b 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -9,6 +9,7 @@ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; mod backend; +pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; mod definition_validation; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a9..72cf466427 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -11,10 +11,12 @@ use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRec #[cfg(test)] use super::{BackendKind, RespondTo}; use crate::app_state::AppState; -use crate::relay::relay_ws_url_with_override; +use crate::commands::{capture_relay_target, fetch_archived_pubkeys_at}; +use std::collections::HashSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; @@ -523,19 +525,35 @@ fn escape_md_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } +/// True iff the relay has archived this instance's identity. Membership is +/// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty +/// set (relay unreachable) fails open — see [`regenerate_nest_context`]. +fn is_archived(record: &ManagedAgentRecord, archived: &HashSet) -> bool { + archived.contains(&record.pubkey.to_ascii_lowercase()) +} + pub fn render_dynamic_section( personas: &[AgentDefinition], agents: &[ManagedAgentRecord], + archived: &HashSet, relay_url: &str, ) -> String { - let active_agents = if agents.is_empty() { + // Every managed agent is eligible on every community — `relay_url` is a + // legacy creation-era field that `effective_agent_relay_url()` deliberately + // ignores, and snapshot-imported records store it empty by design. The only + // roster filter is identity-archive. + let live: Vec<&ManagedAgentRecord> = agents + .iter() + .filter(|a| !is_archived(a, archived)) + .collect(); + let active_agents = if live.is_empty() { "## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*" .to_string() } else { let mut table = "## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|" .to_string(); - for agent in agents { + for agent in live { let role = agent .persona_id .as_deref() @@ -645,7 +663,124 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io Ok(()) } -pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { +/// Serializes nest-context writes so a slow, stale regeneration cannot roll the +/// file back over a newer one. This is an ordered, latest-request-wins gate — +/// not a work coalescer: every superseded generation still performs its relay +/// reads, then drops its result at commit time. Adding a true dirty-loop owner +/// would be a larger change and is unwarranted at this user-driven trigger rate. +/// +/// Each regeneration request claims a monotonic generation *synchronously* at +/// request time (see [`NestRegenGate::claim`]), so the generation encodes +/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's +/// regen before the next edit's. The claimed generation travels with the +/// spawned task and gates its write in [`NestRegenGate::commit`]: a task drops +/// its result once a *newer generation has been requested*, even if that newer +/// generation later fails before it writes. Gating on the highest *requested* +/// generation — not the highest *written* one — is what stops a slow, stale +/// pre-edit render from publishing after a newer post-edit render was claimed +/// and then failed during its relay work (which would otherwise leave the +/// obsolete roster authoritative until the next unrelated trigger). Declared +/// semantic: once a newer regeneration is requested, no older one publishes; +/// if that newer one fails, the file simply waits for the next trigger. +/// +/// `claim` and `commit` share one lock, so the "is this still the newest +/// request?" compare is atomic with the synchronous file write. A bare atomic +/// watermark checked separately from the write would let a new claim slip +/// between an older task's eligibility check and its write; holding the lock +/// across both closes that window (no `await` occurs while it is held). +struct NestRegenGate { + /// Highest generation *requested* so far (`0` = none yet). Advanced by + /// [`claim`] and read by [`commit`]; guarding both under this single lock + /// keeps the eligibility compare atomic with the file write. + highest_requested: Mutex, +} + +impl NestRegenGate { + const fn new() -> Self { + Self { + highest_requested: Mutex::new(0), + } + } + + /// Claim the next generation. Call synchronously at request time so the + /// value reflects when the regeneration was requested, not when its task + /// happens to run. Advancing the shared watermark here is what lets a later + /// [`commit`] recognize — and drop — any older generation's stale render. + fn claim(&self) -> u64 { + let mut requested = self + .highest_requested + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *requested += 1; + *requested + } + + /// Non-blocking [`claim`] against the *exact* lock `claim` takes. Returns + /// `Some(generation)` if it acquired the lock — i.e. a claim could proceed + /// with no contention — or `None` if the lock is already held, meaning a + /// concurrent claim would block on it. Because `claim` and `commit` share + /// `highest_requested`, calling this from inside `commit_hooked`'s + /// under-lock hook reports `None`: the eligibility compare and the write + /// are serialized against any new claim. A design that advanced the + /// watermark under a separate lock (or a lock-free atomic) would report + /// `Some` here — the regression this probe proves absent, with no reliance + /// on elapsed time or thread scheduling. + #[cfg(test)] + fn try_claim(&self) -> Option { + match self.highest_requested.try_lock() { + Ok(mut requested) => { + *requested += 1; + Some(*requested) + } + Err(std::sync::TryLockError::WouldBlock) => None, + Err(std::sync::TryLockError::Poisoned(poisoned)) => { + let mut requested = poisoned.into_inner(); + *requested += 1; + Some(*requested) + } + } + } + + /// Commit `content` for `generation`, dropping the write once a newer + /// generation has been *requested* (regardless of whether that newer + /// generation has written or ever will). Returns whether the file was + /// written. The lock spans the compare and the write so the check-and-write + /// is atomic and no await occurs while it is held. + fn commit(&self, agents_md: &Path, content: &str, generation: u64) -> io::Result { + self.commit_hooked(agents_md, content, generation, || {}) + } + + /// [`commit`] with a hook invoked while the lock is held, after the + /// eligibility compare and before the write. Production passes a no-op, so + /// this is exactly [`commit`]; tests pass a hook that calls [`try_claim`] + /// to prove no claim can land inside the compare-then-write window — the + /// probe reports the lock held here, whereas the flawed + /// separate-watermark/separate-write-lock design would report it free. The + /// `impl FnOnce` monomorphizes the no-op away. + fn commit_hooked( + &self, + agents_md: &Path, + content: &str, + generation: u64, + under_lock: impl FnOnce(), + ) -> io::Result { + let requested = self + .highest_requested + .lock() + .map_err(|_| io::Error::other("nest regen gate lock poisoned"))?; + if generation < *requested { + return Ok(false); + } + under_lock(); + upsert_managed_section(agents_md, content)?; + Ok(true) + } +} + +/// Process-wide ordered write gate for nest-context regeneration. +static NEST_REGEN: NestRegenGate = NestRegenGate::new(); + +pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result<(), String> { let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; let agents_md = nest.join("AGENTS.md"); @@ -656,23 +791,51 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { let personas = load_personas(app)?; let agents = load_managed_agents(app)?; let state = app.state::(); - let relay_url = relay_ws_url_with_override(&state); - let content = render_dynamic_section(&personas, &agents, &relay_url); - upsert_managed_section(&agents_md, &content) + // Capture the relay target once, before any network work, so this + // generation's rendered footer, NIP-11 signer, and snapshot query all + // belong to one relay even if a workspace switch changes the override + // between the two archive awaits below. + let target = capture_relay_target(&state); + // Identity-archived agents live only in the relay's `kind:13535` snapshot; + // local records all read `is_active: true`. Fails open (empty set → render + // everyone) so an unreachable relay can't blank the roster. The archive read + // uses the same captured target as the rendered relay; a later generation's + // task always wins the commit, so a fallback-relay boot render cannot bury a + // later apply_workspace render. + let archived: HashSet = fetch_archived_pubkeys_at(&state, &target) + .await + .into_iter() + .collect(); + let content = render_dynamic_section(&personas, &agents, &archived, &target.ws_url); + NEST_REGEN + .commit(&agents_md, &content, generation) .map_err(|e| format!("regenerate nest context: {e}"))?; Ok(()) } -/// Convenience wrapper: regenerates nest context, logging a warning on failure. +/// Convenience wrapper: claims a regeneration generation, then regenerates on a +/// spawned task, logging a warning on failure. /// /// All call sites treat regeneration as fire-and-forget — agents run fine with /// a stale AGENTS.md, so we warn and continue rather than propagating the error. +/// The generation is claimed *here*, synchronously, so it encodes call order; +/// the spawned task carries it into [`NestRegenGate::commit`], which drops +/// a stale render rather than letting a slow task overwrite a newer file. +/// Archive/unarchive trigger this directly, but the regen races the relay's +/// `kind:13535` snapshot update, so a just-archived agent may still linger for +/// one cycle until the next regen (any agent/team edit or the next launch). pub fn try_regenerate_nest(app: &AppHandle) { - if let Err(error) = regenerate_nest_context(app) { - eprintln!("buzz-desktop: nest context regeneration failed: {error}"); - } + let generation = NEST_REGEN.claim(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = regenerate_nest_context(&app, generation).await { + eprintln!("buzz-desktop: nest context regeneration failed: {error}"); + } + }); } +#[cfg(test)] +mod render_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs new file mode 100644 index 0000000000..ed4ee2c1f9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -0,0 +1,713 @@ +//! Tests for the dynamic AGENTS.md section renderer, the managed-section +//! upsert, and the regeneration gate. Split from `tests.rs` to keep +//! each test file under the repository's per-file line ratchet. + +use super::*; +use std::collections::HashSet; + +/// Relay URL passed to render calls. Since the roster no longer filters on +/// `relay_url`, this is only echoed into the Workspace footer. +const TEST_RELAY: &str = "ws://example.com:3000"; + +fn make_persona(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: name.to_string(), + persona_id: persona_id.map(|s| s.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: TEST_RELAY.to_string(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + env_vars: std::collections::BTreeMap::new(), + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + } +} + +#[test] +fn test_render_dynamic_section_with_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Kit", Some("p1"))]; + let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); + assert!(output.contains("## Workspace")); +} + +#[test] +fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY); + assert!(output.contains("No agents deployed yet")); +} + +#[test] +fn test_render_dynamic_section_agent_no_persona() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Scout", Some("nonexistent"))]; + let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Scout | — | @Scout |")); +} + +#[test] +fn test_render_excludes_archived_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let mut live = make_agent("Live", Some("p1")); + live.pubkey = "aa".repeat(32); + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "bb".repeat(32); + let archived: HashSet = [gone.pubkey.clone()].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY); + + assert!(output.contains("| Live | Builder | @Live |")); + assert!( + !output.contains("Archived"), + "archived agent must not render" + ); +} + +#[test] +fn test_render_archived_match_is_case_insensitive() { + let personas = vec![make_persona("p1", "Builder")]; + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "AB".repeat(32); // uppercase hex in the record + // Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`. + let archived: HashSet = ["ab".repeat(32)].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY); + + assert!( + output.contains("No agents deployed yet"), + "all-archived roster renders the empty placeholder" + ); +} + +#[test] +fn test_render_empty_archived_set_renders_all() { + let personas = vec![make_persona("p1", "Builder")]; + let mut a = make_agent("Kit", Some("p1")); + a.pubkey = "cc".repeat(32); + // Fail-open: an empty snapshot (relay unreachable) must render everyone. + let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); +} + +#[test] +fn test_render_keeps_agent_with_legacy_foreign_relay_pin() { + // `relay_url` is a legacy creation-era field that `effective_agent_relay_url()` + // deliberately ignores — every agent is eligible on every community. A record + // whose stored pin points at a now-defunct relay must still render on the + // active workspace; only identity-archive removes an agent. + let personas = vec![make_persona("p1", "Builder")]; + let here = make_agent("Local", Some("p1")); + let mut elsewhere = make_agent("Foreign", Some("p1")); + elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); + + let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY); + + assert!(output.contains("| Local | Builder | @Local |")); + assert!( + output.contains("| Foreign | Builder | @Foreign |"), + "a legacy foreign relay pin must not hide an agent — the pin is ignored" + ); +} + +#[test] +fn test_render_keeps_snapshot_imported_agent_with_empty_relay_pin() { + // Snapshot-imported records store `relay_url: ""` by design; they resolve + // to the workspace relay at runtime. Such an agent must appear on the active + // workspace, not be hidden by an empty pin. + let personas = vec![make_persona("p1", "Builder")]; + let mut imported = make_agent("Imported", Some("p1")); + imported.relay_url = String::new(); + + let output = render_dynamic_section(&personas, &[imported], &HashSet::new(), TEST_RELAY); + + assert!( + output.contains("| Imported | Builder | @Imported |"), + "an empty relay_url (snapshot-import shape) must still render" + ); +} + +#[test] +fn test_upsert_managed_section_with_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("")); + assert!(result.contains("new section")); + assert!(!result.contains("old section")); + assert!(result.contains("# Header")); + assert!(result.contains("some content")); + assert!(result.contains("after")); +} + +#[test] +fn test_upsert_managed_section_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# Header\n\nexisting content\n").unwrap(); + + upsert_managed_section(&file, "injected section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("# Header")); + assert!(result.contains("existing content")); + assert!(result.contains("")); + assert!(result.contains("injected section")); + let begin_pos = result.find("\nsome middle content\n\nold section\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("new section"), + "new content must be present" + ); + assert!( + result.contains("some middle content"), + "content between markers must survive" + ); + + // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + + // The single BEGIN marker must have a matching END marker after it. + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); + assert!( + end_pos.is_some(), + "an END marker must appear after the appended BEGIN marker" + ); +} + +#[test] +fn test_upsert_begin_only_no_end() { + // A file with BEGIN but no END has an orphan marker. + // find_managed_markers returns None (no END found after BEGIN), + // so strip_orphan_begin_marker removes the BEGIN line. + // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, + // not the body that came after it). + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\norphaned section without end marker\n", + ) + .unwrap(); + + upsert_managed_section(&file, "fresh section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("some content"), + "original body must survive" + ); + assert!( + result.contains("fresh section"), + "new content must be present" + ); + + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result.find(END_MARKER).expect("END marker must be present"); + assert!( + begin_pos < end_pos, + "the appended BEGIN marker must precede the appended END marker" + ); + + // Exactly one BEGIN marker after orphan cleanup. + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); +} + +#[test] +fn test_upsert_duplicate_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "replaced").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains("replaced"), + "replacement content must be present" + ); + assert!( + !result.contains("first block"), + "first block must be replaced" + ); + assert!( + result.contains("second block"), + "second pair content must survive" + ); + assert!( + result.contains("between blocks"), + "text between pairs must survive" + ); +} + +#[test] +fn test_upsert_marker_in_code_block() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. + fs::write( + &file, + "# Header\n\n \n\nReal content here\n", + ) + .unwrap(); + + upsert_managed_section(&file, "appended content").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains(" "), + "indented marker inside code block must be preserved verbatim" + ); + assert!( + result.contains("appended content"), + "new content must be appended" + ); + assert!( + result.contains("Real content here"), + "existing body must survive" + ); + + // The real markers appended at the end must be at line-start (column 0). + let begin_pos = result + .find("\nexisting section\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_first = fs::read_to_string(&file).unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_second = fs::read_to_string(&file).unwrap(); + + assert_eq!( + after_first, after_second, + "upsert must be idempotent: second call must not alter the file" + ); +} + +/// Write an AGENTS.md skeleton with an empty managed section and return its path. +fn agents_md_with_markers(dir: &Path) -> PathBuf { + let file = dir.join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\n\n\n", + ) + .unwrap(); + file +} + +#[test] +fn commit_newer_generation_wins_over_a_stale_finisher() { + // Models the CRUD race: generation A snapshots pre-edit state and its relay + // fetch is slow; generation B snapshots post-edit state and commits first. + // When A finally finishes and commits LAST, its lower generation is dropped + // so the file still reflects B. Ordering of *finishing* is the only variable — + // the generation, claimed at request time, decides the winner. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen_a = gate.claim(); // pre-edit request + let gen_b = gate.claim(); // post-edit request + assert!(gen_a < gen_b); + + // B (newer) commits first. + assert!(gate.commit(&file, "post-edit roster", gen_b).unwrap()); + // A (older) finishes last and must be dropped. + assert!( + !gate.commit(&file, "pre-edit roster", gen_a).unwrap(), + "a stale (lower-generation) render must not overwrite a newer one" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("post-edit roster")); + assert!( + !content.contains("pre-edit roster"), + "final file must reflect the newer generation, not the stale finisher" + ); +} + +#[test] +fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() { + // Models boot→apply_workspace relay switching: the boot regen (generation 1, + // fallback relay) is claimed first but finishes last; the apply_workspace + // regen (generation 2, workspace relay) commits first. The workspace relay + // render must survive even though the fallback-relay task writes afterward. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let boot_gen = gate.claim(); // boot, fallback relay + let apply_gen = gate.claim(); // apply_workspace, workspace relay + + // apply_workspace's render lands first. + assert!(gate + .commit( + &file, + "## Workspace\n- Relay: wss://workspace.example", + apply_gen, + ) + .unwrap()); + // Boot's slower fallback-relay render finishes last and is dropped. + assert!(!gate + .commit( + &file, + "## Workspace\n- Relay: wss://fallback.example", + boot_gen, + ) + .unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("wss://workspace.example")); + assert!( + !content.contains("wss://fallback.example"), + "the fallback-relay boot render must not overwrite the workspace-relay render" + ); +} + +#[test] +fn commit_failed_newer_request_still_supersedes_older_snapshot() { + // Carl 4954831197, case 1: a newer request that never writes must still + // permanently supersede an older snapshot. gen1 (pre-edit) is claimed and + // its relay work is slow; an edit claims gen2 (post-edit); gen2 then FAILS + // during its relay work, so it never commits. When gen1 finally finishes, + // it must NOT publish its obsolete roster — gating on highest-*requested* + // (advanced by gen2's claim) drops it, whereas gating on highest-*written* + // (0, since gen2 never wrote) would wrongly let gen1 publish. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen1 = gate.claim(); // pre-edit request + let gen2 = gate.claim(); // post-edit request + assert!(gen1 < gen2); + + // gen2 fails during relay work and never reaches commit — nothing written. + + // gen1 finishes last; its stale render must be dropped. + assert!( + !gate.commit(&file, "pre-edit roster", gen1).unwrap(), + "an older snapshot must not publish once a newer generation was requested, \ + even if that newer generation failed before writing" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!( + !content.contains("pre-edit roster"), + "the obsolete pre-edit roster must never become authoritative" + ); +} + +#[test] +fn commit_claim_at_the_older_tasks_cutover_supersedes_it() { + // Carl 4954831197, case 2: a claim arriving at the older task's commit + // cutover must not slip between the eligibility compare and the write. + // gen1 becomes eligible and enters `commit`; while it holds the lock + // (after the compare, before the write) a claim is attempted. The correct + // single-lock gate shares `highest_requested` between `claim` and + // `commit`, so that claim cannot acquire the lock until gen1's write + // releases it — the flawed separate-watermark/separate-write-lock design + // Carl warned about would let the claim proceed immediately. + // + // Determinism: the under-lock hook calls `try_claim`, a non-blocking claim + // against the exact lock `claim` takes, and asserts it reports the lock + // held (`None`). This is a direct statement about the gate's locking with + // no thread, channel, or sleep — the correct design necessarily returns + // `None` and the separate-watermark design necessarily returns `Some`, so + // the discriminator cannot be flipped by scheduler timing. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen1 = gate.claim(); + + let wrote_gen1 = gate + .commit_hooked(&file, "gen1 roster", gen1, || { + // We are past the eligibility compare and hold the lock. A claim + // attempted now must find the shared lock held — proving the + // compare and the write are atomic against any new claim. + assert!( + gate.try_claim().is_none(), + "a claim must not acquire the gate while an older commit holds \ + the shared lock between its eligibility check and its write — \ + the eligibility compare is not atomic with the write \ + (separate-watermark design)" + ); + }) + .unwrap(); + assert!( + wrote_gen1, + "gen1 was still the highest request when it entered commit, so its write \ + is legitimate; the newer request only lands after the lock releases" + ); + + // The lock is free once commit returns, so a newer request now claims and + // may publish over gen1. + let gen2 = gate.claim(); + assert!(gen1 < gen2); + assert!(gate.commit(&file, "gen2 roster", gen2).unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("gen2 roster")); + assert!(!content.contains("gen1 roster")); +} + +#[test] +fn commit_equal_generation_is_allowed() { + // The gate rejects only strictly-lower generations. Re-committing the same + // generation (e.g. a retried request) is permitted and refreshes the file. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen = gate.claim(); + assert!(gate.commit(&file, "first", gen).unwrap()); + assert!( + gate.commit(&file, "second", gen).unwrap(), + "an equal generation must still be allowed to write" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("second")); +} + +#[test] +fn commit_poisoned_lock_returns_error_instead_of_panicking() { + // A poisoned gate lock must degrade to an io::Error so the fire-and-forget + // caller warns and continues, never panicking the desktop process (root + // AGENTS.md: no new expect() in production paths). Poison the lock by + // panicking a thread while it holds the guard, then assert commit yields + // Err rather than unwinding. + let gate = std::sync::Arc::new(NestRegenGate::new()); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + let gen = gate.claim(); + + let poisoner = gate.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoner.highest_requested.lock().unwrap(); + panic!("poison the gate lock"); + }) + .join(); + + let result = gate.commit(&file, "after poison", gen); + assert!( + result.is_err(), + "a poisoned lock must surface as an error, not a panic" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 67cdb5fbaf..bc67a5b69e 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -422,415 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() { ); } -fn make_persona(id: &str, display_name: &str) -> AgentDefinition { - AgentDefinition { - id: id.to_string(), - display_name: display_name.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: std::collections::BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: String::new(), - updated_at: String::new(), - } -} - -fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: String::new(), - name: name.to_string(), - persona_id: persona_id.map(|s| s.to_string()), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::default(), - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::default(), - respond_to_allowlist: vec![], - env_vars: std::collections::BTreeMap::new(), - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - -#[test] -fn test_render_dynamic_section_with_agents() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Kit", Some("p1"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Kit | Builder | @Kit |")); - assert!(output.contains("| Name | Persona | How to address |")); - assert!(output.contains("## Workspace")); -} - -#[test] -fn test_render_dynamic_section_empty() { - let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); - assert!(output.contains("No agents deployed yet")); -} - -#[test] -fn test_render_dynamic_section_agent_no_persona() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Scout", Some("nonexistent"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Scout | — | @Scout |")); -} - -#[test] -fn test_upsert_managed_section_with_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("")); - assert!(result.contains("new section")); - assert!(!result.contains("old section")); - assert!(result.contains("# Header")); - assert!(result.contains("some content")); - assert!(result.contains("after")); -} - -#[test] -fn test_upsert_managed_section_without_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write(&file, "# Header\n\nexisting content\n").unwrap(); - - upsert_managed_section(&file, "injected section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("# Header")); - assert!(result.contains("existing content")); - assert!(result.contains("")); - assert!(result.contains("injected section")); - let begin_pos = result.find("\nsome middle content\n\nold section\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("new section"), - "new content must be present" - ); - assert!( - result.contains("some middle content"), - "content between markers must survive" - ); - - // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); - - // The single BEGIN marker must have a matching END marker after it. - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); - assert!( - end_pos.is_some(), - "an END marker must appear after the appended BEGIN marker" - ); -} - -#[test] -fn test_upsert_begin_only_no_end() { - // A file with BEGIN but no END has an orphan marker. - // find_managed_markers returns None (no END found after BEGIN), - // so strip_orphan_begin_marker removes the BEGIN line. - // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, - // not the body that came after it). - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\norphaned section without end marker\n", - ) - .unwrap(); - - upsert_managed_section(&file, "fresh section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("some content"), - "original body must survive" - ); - assert!( - result.contains("fresh section"), - "new content must be present" - ); - - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result.find(END_MARKER).expect("END marker must be present"); - assert!( - begin_pos < end_pos, - "the appended BEGIN marker must precede the appended END marker" - ); - - // Exactly one BEGIN marker after orphan cleanup. - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); -} - -#[test] -fn test_upsert_duplicate_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "replaced").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains("replaced"), - "replacement content must be present" - ); - assert!( - !result.contains("first block"), - "first block must be replaced" - ); - assert!( - result.contains("second block"), - "second pair content must survive" - ); - assert!( - result.contains("between blocks"), - "text between pairs must survive" - ); -} - -#[test] -fn test_upsert_marker_in_code_block() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. - fs::write( - &file, - "# Header\n\n \n\nReal content here\n", - ) - .unwrap(); - - upsert_managed_section(&file, "appended content").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains(" "), - "indented marker inside code block must be preserved verbatim" - ); - assert!( - result.contains("appended content"), - "new content must be appended" - ); - assert!( - result.contains("Real content here"), - "existing body must survive" - ); - - // The real markers appended at the end must be at line-start (column 0). - let begin_pos = result - .find("\nexisting section\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_first = fs::read_to_string(&file).unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_second = fs::read_to_string(&file).unwrap(); - - assert_eq!( - after_first, after_second, - "upsert must be idempotent: second call must not alter the file" - ); -} - #[test] fn refresh_agents_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index a6a50540bb..734772d73d 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -118,6 +118,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 682fbef62f..af8cfe6618 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -59,6 +59,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c005510907..f7f5d5c5d0 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1465,9 +1465,8 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. + // User env_vars must win over baked defaults; in OSS builds baked map is empty, + // so this validates the user-env layer is present in the output. let mut env_vars = BTreeMap::new(); env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); env_vars.insert( @@ -1531,6 +1530,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, }; let runtime = known_acp_runtime_exact("buzz-agent"); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 8b57b161b7..923530d34b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,6 +14,7 @@ use crate::{ util::now_iso, }; +use super::claude_config::{apply_claude_model_env, apply_effort_env}; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; @@ -775,17 +776,8 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); - // ── Git credential helper for Buzz relay ────────────────────────── - // - // Agents need to clone/push repos hosted on the Buzz relay's git - // server, which authenticates via NIP-98. The `git-credential-nostr` - // binary signs auth events using the agent's nostr key. - // - // We configure git via GIT_CONFIG_COUNT env vars (ephemeral, no - // filesystem writes) scoped to the relay's git URL so we don't - // interfere with other remotes (e.g. GitHub). - // - // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY — keep in sync. + // Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr. + // Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY. if let Some(cred_helper) = resolve_command("git-credential-nostr") { let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url); @@ -810,17 +802,27 @@ pub fn spawn_agent_child( ); } - // ── User env vars: definition floor + global + live persona + agent overrides ── - // - // `descriptor.env` is the fully-layered result from `resolve_effective_harness_descriptor`: - // baked floor → runtime metadata → definition env (harness author defaults) → - // global → live persona → per-agent, with reserved-key and malformed-key filtering - // applied. Writing it last lets user-provided values win over every Buzz-set env - // written above — reserved keys were already stripped from descriptor.env so they - // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc. + // User env (descriptor.env): fully-layered floor→runtime→definition→global→persona→agent, + // reserved-key filtered. Written last so user-explicit values win over Buzz-set env. for (key, value) in &descriptor.env { command.env(key, value); } + + // B5: carry persisted effort; harness resolves thought_level configId at first session. + // Written AFTER descriptor.env so the canonical persisted value wins over any + // user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern + // (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is + // None there is no canonical value to assert, so env passthrough stands — user env + // legitimately seeds startup effort in that case. + apply_effort_env(&mut command, record.effort_level.as_deref()); + + // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. + // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env + // would be ambiguous). + if record.backend == super::BackendKind::Local && runtime_meta.is_some_and(|r| r.id == "claude") + { + apply_claude_model_env(&mut command, effective_model.as_deref()); + } configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 792a275b05..9076766b2e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -90,5 +90,6 @@ pub(super) fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 357f5f1e26..8a6f68a693 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -31,6 +31,7 @@ use std::collections::BTreeMap; use serde::Serialize; use super::{ + claude_config::EFFORT_LEVEL_ENV_VAR, effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, persona_events::preview_prospective_persona_snapshot, @@ -126,6 +127,31 @@ pub(crate) struct SpawnConfigSnapshot { pub idle_timeout_seconds: Option, pub max_turn_duration_seconds: Option, pub parallelism: u32, + /// The startup effort the harness will actually apply, resolved by + /// [`effective_effort`]: the persisted canonical `record.effort_level` when + /// present, else the user-seeded `BUZZ_ACP_EFFORT_LEVEL` from the layered + /// env. This is the *sole* representation of effort in the snapshot — the + /// key is stripped from `env` (see `from_inputs`) so an authority handoff + /// that leaves the effective value unchanged (canonical `low` replacing a + /// user env `low`, or the reverse) produces no spurious drift entry, and an + /// env-only edit still surfaces as exactly one `effort_level` entry. + pub effort_level: Option, +} + +/// The startup effort a spawn would actually apply, mirroring `apply_effort_env` +/// exactly: the persisted canonical `record.effort_level` wins, and only when it +/// is absent does a user-supplied `BUZZ_ACP_EFFORT_LEVEL` from the layered env +/// seed startup effort. This is the resolver input for the snapshot's single +/// `effort_level` representation; the same precedence runs at spawn time in +/// `runtime.rs`, so badge and process can never disagree. +pub(crate) fn effective_effort( + record: &ManagedAgentRecord, + descriptor_env: &BTreeMap, +) -> Option { + record + .effort_level + .clone() + .or_else(|| descriptor_env.get(EFFORT_LEVEL_ENV_VAR).cloned()) } impl SpawnConfigSnapshot { @@ -151,7 +177,17 @@ impl SpawnConfigSnapshot { .and_then(|runtime| runtime.mcp_command) .unwrap_or("") .to_string(), - env: descriptor.env.clone(), + // Effort has ONE representation in the snapshot: `effort_level` + // below, always holding `effective_effort`. Stripping the env key + // here means a canonical/user-env authority handoff at the same + // value is a no-op (no phantom `env.BUZZ_ACP_EFFORT_LEVEL` add or + // remove) and an env-only effort edit surfaces as exactly one + // `effort_level` entry rather than a duplicate under `env.`. + env: { + let mut env = descriptor.env.clone(); + env.remove(EFFORT_LEVEL_ENV_VAR); + env + }, relay_url: relay_url.to_string(), team_instructions: team_instructions.map(str::to_string), system_prompt: system_prompt.map(str::to_string), @@ -178,6 +214,11 @@ impl SpawnConfigSnapshot { // pool and must badge. The diff surface consequently displays the // effective value — that is correct, it is what actually runs. parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), + // Sole effort representation — see the field doc and the `env` + // strip above. Resolver reads the record's canonical value and the + // raw descriptor env (before the strip), so a user-seeded env value + // is preserved as the effective effort when no canonical is set. + effort_level: effective_effort(record, &descriptor.env), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs index a61eb92e2e..0ae3009bae 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs @@ -103,6 +103,7 @@ fn policy_for(path: &str) -> MaskPolicy { // acp_command / command / mcp_command — resolved binary names // session_title — display chrome // model / provider — catalog ids + // effort_level — non-secret effort enum // respond_to / respond_to_allowlist — gate mode + pubkeys // idle_timeout_seconds / max_turn_duration_seconds / parallelism // — numeric limits diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index a7a8cab93e..e21dc4735c 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -28,6 +28,7 @@ fn base() -> SpawnConfigSnapshot { idle_timeout_seconds: Some(600), max_turn_duration_seconds: Some(7200), parallelism: 1, + effort_level: Some("high".into()), } } @@ -70,6 +71,7 @@ fn mutations() -> Vec { s.max_turn_duration_seconds = None }), ("parallelism", |s| s.parallelism = 8), + ("effort_level", |s| s.effort_level = None), ] } @@ -570,3 +572,37 @@ fn unstamped_agent_yields_no_badge_and_no_entries() { ); } } + +// ── B5 effort lifecycle: restart-diff and re-stamp ─────────────────────── + +#[test] +fn tracked_running_old_effort_edited_to_new_yields_effort_level_diff() { + // A process was stamped at effort `high`; the record's canonical effort is + // later edited to `low`. Until a restart re-stamps, the tracked pair must + // light the badge and name exactly `effort_level`. + let stamped = base(); // effort_level = high + let mut current = base(); + current.effort_level = Some("low".into()); + let (needs_restart, entries) = eligible(false, &stamped, ¤t, None, None); + assert!(needs_restart); + assert_eq!(fields(&entries), vec!["effort_level"]); + assert_eq!( + change_at(&entries, "effort_level"), + &RestartChange::Value { + before: Value::String("high".into()), + after: Value::String("low".into()), + } + ); +} + +#[test] +fn restart_restamps_effort_and_clears_the_badge() { + // After the edit above, a restart stamps the new effort, so stamped and + // current agree again: the badge clears and no entry remains. + let mut restamped = base(); + restamped.effort_level = Some("low".into()); + let current = restamped.clone(); + let (needs_restart, entries) = eligible(false, &restamped, ¤t, None, None); + assert!(!needs_restart); + assert!(entries.is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 89bba15cee..b007e0b2ff 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -34,6 +34,12 @@ fn snapshot( snapshot_with_policy(record, personas, teams, workspace_relay, global, false) } +/// `snapshot` with the fixed no-persona/no-team/default-global shape the effort +/// tests share, so their call sites read as `snap(&record)` instead of wrapping. +fn snap(record: &ManagedAgentRecord) -> serde_json::Value { + snapshot(record, &[], &[], "wss://ws.example", &Default::default()) +} + fn record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), @@ -90,6 +96,7 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } @@ -925,3 +932,7 @@ fn openclaw_cap_crossing_parallelism_snapshots_differ() { "parallelism 8 (clamps to 5) and 3 (runs as 3) must produce different snapshots" ); } + +#[cfg(test)] +#[path = "tests_ext.rs"] +mod ext; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs new file mode 100644 index 0000000000..dd708b6e59 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -0,0 +1,189 @@ +//! B5 effort lifecycle tests split out of `spawn_snapshot/tests.rs` to hold +//! that file under the 1000-line file-size ratchet. +//! +//! Included as `mod ext` inside `tests.rs`, so `use super::*` gives access to +//! its `record`, `snap`, and `record_with_env_effort` helpers. + +use super::*; + +#[test] +fn effort_set_then_cleared_round_trips_to_no_effort_projection() { + // Persist a canonical effort, then clear it: the projection must return to + // the exact no-effort baseline, so the badge lights on set and clears on + // clear rather than sticking. + let baseline = snap(&record()); + let mut set = record(); + set.effort_level = Some("high".into()); + assert_ne!(baseline, snap(&set), "setting canonical effort must badge"); + // Clear the SAME record back to None — the projection must return to the + // exact no-effort baseline, proving the round-trip clears rather than a + // fresh record merely matching baseline. + set.effort_level = None; + assert_eq!( + baseline, + snap(&set), + "clearing canonical effort restores the no-effort projection" + ); +} + +#[test] +fn shadowed_user_env_effort_edit_under_canonical_is_empty_diff() { + // Canonical `high` shadows the user env seed. Editing that seed low→medium + // changes nothing effective (canonical wins and the env key is stripped), + // so the projections are identical and no badge lights. + let mut low_env = record_with_env_effort("low"); + low_env.effort_level = Some("high".into()); + let mut medium_env = record_with_env_effort("medium"); + medium_env.effort_level = Some("high".into()); + assert_eq!( + snap(&low_env), + snap(&medium_env), + "editing a canonical-shadowed user env must not badge" + ); +} + +#[test] +fn clearing_canonical_reveals_env_fallback_and_creates_a_diff() { + // Canonical `high` over a user env seed `low`: clearing the canonical drops + // the effective effort to the env fallback `low`, a real change that badges. + let mut canonical = record_with_env_effort("low"); + canonical.effort_level = Some("high".into()); + let env_only = record_with_env_effort("low"); + assert_ne!( + snap(&canonical), + snap(&env_only), + "clearing canonical must reveal the env fallback and badge" + ); +} + +// ── B5 effort: single canonical representation ─────────────────────────── +// +// `effective_effort` and the snapshot's `effort_level` field are the sole +// carrier of startup effort. `BUZZ_ACP_EFFORT_LEVEL` is stripped from the +// snapshot `env` so an authority handoff at an unchanged effective value +// (canonical replacing a user-env seed, or the reverse) raises no spurious +// restart badge, while a genuine effort change surfaces exactly once. + +/// Look up the `env.BUZZ_ACP_EFFORT_LEVEL` leaf of a canonical snapshot, if any. +fn effort_env_leaf(canonical: &serde_json::Value) -> Option<&serde_json::Value> { + canonical + .get("env") + .and_then(|env| env.get("BUZZ_ACP_EFFORT_LEVEL")) +} + +/// A record whose user env seeds `BUZZ_ACP_EFFORT_LEVEL` (the pre-canonical +/// authority: no persisted `effort_level`, effort comes from user env_vars). +fn record_with_env_effort(value: &str) -> ManagedAgentRecord { + let mut rec = record(); + rec.env_vars + .insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); + rec +} + +#[test] +fn effective_effort_prefers_persisted_canonical_over_user_env() { + // Canonical wins, mirroring spawn's `apply_effort_env` (written after the + // user env layer). The env value is ignored when a canonical is present. + let mut rec = record(); + rec.effort_level = Some("high".into()); + let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); + assert_eq!(effective_effort(&rec, &env).as_deref(), Some("high")); +} + +#[test] +fn effective_effort_falls_back_to_user_env_when_no_canonical() { + // No persisted canonical → the user-seeded env value is the effective + // startup effort, exactly what a spawn would leave in place. + let rec = record(); + let env = BTreeMap::from([("BUZZ_ACP_EFFORT_LEVEL".to_string(), "low".to_string())]); + assert_eq!(effective_effort(&rec, &env).as_deref(), Some("low")); +} + +#[test] +fn effective_effort_is_none_without_canonical_or_env() { + assert_eq!(effective_effort(&record(), &BTreeMap::new()), None); +} + +#[test] +fn snapshot_carries_effort_in_field_not_env() { + // Always-canonicalize: a user-seeded effort reaches the snapshot ONLY as + // the `effort_level` field; the raw env key is stripped so effort has one + // representation, never two. + let canonical = snap(&record_with_env_effort("low")); + assert_eq!( + canonical.get("effort_level").and_then(|v| v.as_str()), + Some("low"), + "effective effort must land in the effort_level field" + ); + assert_eq!( + effort_env_leaf(&canonical), + None, + "BUZZ_ACP_EFFORT_LEVEL must be stripped from the snapshot env" + ); +} + +#[test] +fn equal_value_effort_authority_handoff_env_to_canonical_is_no_op() { + // User env `low` (no canonical) → persisted canonical `low` while the env + // seed remains: the effective effort is `low` either way, so a restart + // would change nothing. Old raw-env snapshots would have shown drift; the + // single canonical representation makes the projections identical. + let env_authority = record_with_env_effort("low"); + let mut canonical_authority = record_with_env_effort("low"); + canonical_authority.effort_level = Some("low".into()); + assert_eq!( + snap(&env_authority), + snap(&canonical_authority), + "an authority handoff at the same effort value must not badge" + ); +} + +#[test] +fn equal_value_effort_authority_handoff_canonical_to_env_is_no_op() { + // The reverse direction: canonical `low` (env seed present) → env `low` + // only (canonical cleared). Effective effort stays `low`; no badge. + let mut canonical_authority = record_with_env_effort("low"); + canonical_authority.effort_level = Some("low".into()); + let env_authority = record_with_env_effort("low"); + assert_eq!( + snap(&canonical_authority), + snap(&env_authority), + "clearing the canonical while the env seed holds the same value must not badge" + ); +} + +#[test] +fn env_only_effort_edit_changes_effort_level_not_env() { + // An env-only effort edit (no canonical) moves the single `effort_level` + // representation and never reintroduces an `env.BUZZ_ACP_EFFORT_LEVEL` + // leaf, so the diff names `effort_level` once rather than duplicating it. + let low = snap(&record_with_env_effort("low")); + let high = snap(&record_with_env_effort("high")); + assert_ne!( + low, high, + "an env-only effort edit must change the snapshot" + ); + assert_eq!( + low.get("effort_level").and_then(|v| v.as_str()), + Some("low") + ); + assert_eq!( + high.get("effort_level").and_then(|v| v.as_str()), + Some("high") + ); + assert_eq!(effort_env_leaf(&low), None); + assert_eq!(effort_env_leaf(&high), None); +} + +#[test] +fn canonical_effort_edit_changes_snapshot() { + let mut low = record(); + low.effort_level = Some("low".into()); + let mut high = record(); + high.effort_level = Some("high".into()); + assert_ne!( + snap(&low), + snap(&high), + "a canonical effort edit must trip the restart badge" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 5073d9c407..2b6918b16e 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -310,6 +310,7 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index d66e68979c..ff7900d392 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -214,6 +214,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3b0641cb67..9049482de3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -154,6 +154,7 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + effort_level: None, } } } @@ -439,24 +440,10 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, -} - -/// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. -/// -/// Feature-independent on purpose: the field is always present in the record -/// schema so saved agents round-trip identically whether or not the `mesh-llm` -/// feature is compiled in. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RelayMeshConfig { - /// The served model id this agent routes to (e.g. "Qwen3"). - /// - /// `alias` because this struct crosses two boundaries with different - /// casing conventions: the TS create request sends camelCase - /// (`relayMesh: { modelRef }` — `rename_all` on the request does not - /// recurse into nested structs), while persisted records use snake_case. - /// Serialization stays `model_ref` so saved records are stable. - #[serde(alias = "modelRef")] - pub model_ref: String, + /// Canonical Claude Code effort level. Injected as `BUZZ_ACP_EFFORT_LEVEL` at spawn + /// so the harness applies it via `session/set_config_option` at session creation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_level: Option, } #[derive(Debug)] @@ -991,6 +978,8 @@ pub fn resolve_mint_behavioral_defaults( mod catalog_source; pub use catalog_source::CatalogSource; +mod relay_mesh; +pub use relay_mesh::RelayMeshConfig; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs new file mode 100644 index 0000000000..a9ec2d2838 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +/// Typed relay-mesh configuration carried on a [`super::ManagedAgentRecord`]. +/// +/// Feature-independent on purpose: the field is always present in the record +/// schema so saved agents round-trip identically whether or not the `mesh-llm` +/// feature is compiled in. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RelayMeshConfig { + /// The served model id this agent routes to (e.g. "Qwen3"). + /// + /// `alias` because this struct crosses two boundaries with different + /// casing conventions: the TS create request sends camelCase + /// (`relayMesh: { modelRef }` — `rename_all` on the request does not + /// recurse into nested structs), while persisted records use snake_case. + /// Serialization stays `model_ref` so saved records are stable. + #[serde(alias = "modelRef")] + pub model_ref: String, +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 685f83b799..69b7b9ab1f 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -31,7 +31,7 @@ pub fn relay_ws_url() -> String { /// Read the workspace relay URL override, if set. Returns `None` when no /// override is active or when the mutex is poisoned (best-effort). -fn workspace_relay_override(state: &AppState) -> Option { +pub(crate) fn workspace_relay_override(state: &AppState) -> Option { state .relay_url_override .lock() diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b72..96aa7acbac 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -894,6 +894,7 @@ export function AppShell() { /> ) : null} ; @@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = { export function AppShellChannelSurface({ children, + hasCommunityRail, isHuddleRoom, isHuddleRoomStarting, mainInsetRef, terminal, }: AppShellChannelSurfaceProps) { + const { isMobile, openMobile, state: sidebarState } = useSidebar(); + const hasCollapsedSidebarGutter = + !isHuddleRoom && + !hasCommunityRail && + (isMobile ? !openMobile : sidebarState === "collapsed"); + return ( + {hasCollapsedSidebarGutter ? ( +
+ ) : null} {isHuddleRoom && !isHuddleRoomStarting ? : null} {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index cda6c0f2ed..e8b9320794 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,13 +1,13 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; const ZOOM_STEP = 0.1; -const BASE_FONT_SIZE_PX = 16; const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale"; type ZoomAction = "increase" | "decrease" | "reset"; @@ -76,13 +76,12 @@ function readStoredZoomFactor() { } function applyTextScale(zoomFactor: number) { + applyTextZoomFactor(zoomFactor); if (zoomFactor === DEFAULT_ZOOM_FACTOR) { - document.documentElement.style.fontSize = ""; window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); return; } - document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor)); } @@ -120,9 +119,21 @@ export function useWebviewZoomShortcuts() { applyTextScale(nextZoomFactor); } + function handleStorage(event: StorageEvent) { + if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) { + return; + } + + const storedZoomFactor = readStoredZoomFactor(); + zoomFactorRef.current = storedZoomFactor; + applyTextZoomFactor(storedZoomFactor); + } + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("storage", handleStorage); return () => { window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("storage", handleStorage); }; }, []); } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index fa352284dd..88f2a3c982 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -201,6 +201,40 @@ with a TypeScript lookup table or an id comparison in a component. fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile content. +14. **Thinking effort has two surfaces: a local-only WRITE control and a + read-only two-facts DISPLAY.** The write control is `EffortPickerField` + (`ui/EffortPickerField.tsx`), a self-contained section component mounted in + `AgentInstanceEditDialog` beside the Model block. It is direct-write, not + part of the frozen `UpdateManagedAgentInput` shape: each selection calls + `persistAgentEffortLevel` and invalidates the config-surface query, mirroring + the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and + option compute live in the pure helper `ui/effortPicker.ts` + (`effortPickerState`): the picker renders only when + `agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId` + has been discovered from the running session (absent pre-first-session and + for runtimes/models without effort support). Local-only is load-bearing, not + cosmetic — the Rust command rejects non-local backends because remote effort + is set at deploy time via `policy_env`. Because it reads its inputs from the + config surface the dialog already fetches (`useAgentConfigSurface`) and owns + its own mutation, it does **not** thread new props through the over-1000-line + dialog (see rule 11): keep effort state inside the section component, never + as dialog-level props. The read-only display is the `thinkingEffort` + normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which + already shows both facts — `field.value` (canonical, the effort the next + spawn will launch with) and, when a running ACP session differs, + `field.overriddenValue` struck through (the live session's current effort). + No component owns "configured vs current" logic; the reader's canonical tier + ordering feeds both facts. Do not add a second effort write path or restate + the two-facts logic in a component. + + **Cut invariant — live mid-conversation effort machinery was deliberately + removed.** Effort is spawn-scoped only: the worker holds one `startup_effort` + read from `BUZZ_ACP_EFFORT_LEVEL` and applies it once at session creation + (`apply_startup_effort` in `buzz-acp/src/pool.rs`); there is no pool-level + effort authority, no live effort switching, and no effort-ack frame. Do not + reintroduce a live effort-switch RPC, a pool effort field, or a + mid-conversation effort control without a plan ruling. The archived live-effort + machinery lives on `archive/claude-config-gaps-live-effort` for reference only. 12. **Owner-only builds discover only verified same-owner remote agents.** The native `list_relay_agents` boundary authenticates ownership through the @@ -242,6 +276,11 @@ with a TypeScript lookup table or an id comparison in a component. every profile tab when opened from Agents and from the agent's DM. - `ui/AgentConfigPanelPresentation.test.mjs` — shared profile/agent config rows show only effective values, with an em dash for unknown values. +- `ui/effortPicker.test.mjs` — `effortPickerState` gating (local + discovered + `effortConfigId` renders; provider backend or missing configId hides) and + option/preselect compute, plus `effortSelectionToPersistedValue` sentinel → + null. This is where the v4 provider regression is pinned: the write control + must never render for a provider backend. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs index 737d84b862..4a79d32837 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -3,10 +3,18 @@ import test from "node:test"; import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts"; -const MODEL = "goose-claude-fable-5"; +const REQUEST_ID = "req-abc"; +const CH_A = "channel-a"; +const CH_B = "channel-b"; function frame(status, overrides = {}) { - return { type: "switch_model", status, modelId: MODEL, ...overrides }; + return { + type: "switch_model", + status, + requestId: REQUEST_ID, + channelId: CH_A, + ...overrides, + }; } /** @@ -15,7 +23,7 @@ function frame(status, overrides = {}) { * no-ops, matching `observerRelayStore`), a manual timeout, and a deferred * `sendSwitches` the test resolves explicitly. */ -function harness(channelCount) { +function harness(channelIds, requestId = REQUEST_ID) { let listener = null; let timeoutCb = null; let unsubscribeCalls = 0; @@ -26,8 +34,8 @@ function harness(channelCount) { }); const outcome = awaitLiveSwitchOutcome({ - channelCount, - modelId: MODEL, + requestId, + channelIds, subscribe: (fn) => { listener = fn; return () => { @@ -61,47 +69,85 @@ function harness(channelCount) { }; } +const drainMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => { - const h = harness(2); + const h = harness([CH_A, CH_B]); // 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")); + h.push(frame("unsupported_model", { channelId: CH_B })); assert.equal(await h.outcome, "unsupported"); }); -test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", async () => { - const h = harness(3); +test("awaitLiveSwitchOutcome resolves ok only after every distinct channel acks", async () => { + const h = harness([CH_A, CH_B]); 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")); + // Terminal success for channel A alone must not settle a two-channel pick. + h.push(frame("switched", { channelId: CH_A })); await drainMicrotasks(); - assert.equal(settled, false, "must not resolve on the first ack"); + assert.equal(settled, false, "must not resolve before every channel acks"); + + h.push(frame("switched", { channelId: CH_B })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome settles not_delivered immediately on a turn_ending frame without waiting for other channels or the timeout", async () => { + // `turn_ending` means the control oneshot was already consumed (a prior + // cancel is ending the turn) — the switch can't land and nothing applies + // later. It must fail-fast to "not_delivered", NOT count as a positive + // terminal. Three channels prove it never traverses the success-count path. + const h = harness([CH_A, CH_B, "channel-c"]); + h.push(frame("turn_ending")); + assert.equal(await h.outcome, "not_delivered"); + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); + + // A later positive frame must not re-resolve or re-unsubscribe. + h.push(frame("switched")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + +test("awaitLiveSwitchOutcome settles not_delivered immediately on a no_active_turn frame", async () => { + // `no_active_turn` means neither an in-flight task nor an idle session-owning + // agent existed by the time the harness received the switch (a stale + // `activeTurns` snapshot). Nothing was applied and nothing rides a later + // session — fail-fast to "not_delivered", never a false "ok". + const h = harness([CH_A, CH_B]); + h.push(frame("no_active_turn")); + assert.equal(await h.outcome, "not_delivered"); + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("awaitLiveSwitchOutcome ignores an unknown future status and settles via a real switched terminal", async () => { + // A status the picker doesn't know (a newer harness) must be inert — never + // default-counted as success. The pick stays open until a real `switched` + // terminal (or the timeout) settles it. + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("some_future_status")); + await drainMicrotasks(); + assert.equal(settled, false, "an unknown status must not settle the pick"); 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); + const h = harness([CH_A, CH_B]); h.push(frame("unsupported_model")); assert.equal(await h.outcome, "unsupported"); assert.equal(h.unsubscribeCalls, 1); @@ -113,42 +159,309 @@ test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes 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 }); +test("awaitLiveSwitchOutcome settles failed immediately on an adapter-refused frame without waiting for other channels or the timeout", async () => { + const h = harness([CH_A, CH_B, "channel-c"]); + // Three channels, so a success-path impl would need three acks. A single + // `failure` frame must fail-fast to "failed" (not "unsupported", not "ok") + // before the other two channels reply — proving it never traverses the + // success-count path. + h.push(frame("failure")); + assert.equal(await h.outcome, "failed"); + // The timeout was cancelled (no 8s wait) and the listener detached exactly + // once — the frame settled synchronously, not via the fallback. + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); + + // A later frame must not re-resolve or re-unsubscribe. + h.push(frame("switched")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + +test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then settles failed when the adapter rejection arrives", async () => { + // The real busy-path producer order: the harness acks `sent` immediately + // (the switch was delivered to the in-flight turn), then — after the requeued + // session consults the adapter — emits `failure` seconds later. With one + // active channel a first-ack-resolves impl would settle "ok" on `sent` and + // detach before `failure` arrives; this regresses that. + const h = harness([CH_A]); let settled = false; void h.outcome.then(() => { settled = true; }); - await Promise.resolve(); + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "provisional `sent` must not resolve the pick"); + assert.equal(h.unsubscribeCalls, 0, "subscription stays alive after `sent`"); + assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`"); + + h.push(frame("failure")); + assert.equal(await h.outcome, "failed"); + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then resolves pending via the timeout when no positive terminal arrives", async () => { + // Busy-path success now emits a positive `switched` terminal when the + // requeued session applies the model — but a busy turn can outlast the + // fallback timeout. If no terminal arrives in time, the pick resolves + // `"pending"` (accepted, apply deferred), NEVER a false `"ok"`. + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "provisional `sent` must not resolve the pick"); + assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`"); + + h.fireTimeout(); + assert.equal(await h.outcome, "pending"); + assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes"); +}); + +test("awaitLiveSwitchOutcome resolves ok when the busy-path deferred apply emits a positive switched terminal", async () => { + // The K1 mirror case: after the provisional `sent`, the requeued session + // applies the model and the harness emits a real `switched` terminal before + // the timeout. That positive frame — not timeout silence — resolves `"ok"`. + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("sent")); + await drainMicrotasks(); + assert.equal(settled, false, "provisional `sent` must not resolve the pick"); + + h.push(frame("switched")); + assert.equal(await h.outcome, "ok"); + assert.equal( + h.cancelTimeoutCalls, + 1, + "positive terminal cancels the timeout", + ); + assert.equal(h.unsubscribeCalls, 1); +}); + +test("awaitLiveSwitchOutcome never resolves ok when a delayed rejection lands after the timeout already resolved pending", async () => { + // The K1 named pin: a busy switch whose turn outlasts the timeout. The pick + // resolves `"pending"` at the timeout; the deferred apply then rejects. The + // late `failure` frame must not re-resolve, and the outcome is never `"ok"`. + const h = harness([CH_A]); + + h.push(frame("sent")); + h.fireTimeout(); + assert.equal(await h.outcome, "pending"); + assert.equal(h.unsubscribeCalls, 1, "timeout fallback detaches the listener"); + + // Deferred apply rejects after the fact: inert, the listener is gone. + h.push(frame("failure")); + assert.equal( + h.unsubscribeCalls, + 1, + "no re-resolve or re-unsubscribe on a late frame", + ); +}); + +test("awaitLiveSwitchOutcome ignores frames for a different request id or control type", async () => { + const h = harness([CH_A]); + // A replayed terminal frame from an EARLIER pick carries a different + // requestId; it must not advance this pick's count. + h.push(frame("switched", { requestId: "req-stale" })); + h.push({ + type: "cancel_turn", + status: "sent", + requestId: REQUEST_ID, + channelId: CH_A, + }); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + await drainMicrotasks(); 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); +test("awaitLiveSwitchOutcome resolves pending via the timeout fallback when the harness never replies", async () => { + const h = harness([CH_A, CH_B]); h.fireTimeout(); - assert.equal(await h.outcome, "ok"); + assert.equal(await h.outcome, "pending"); assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes"); }); test("awaitLiveSwitchOutcome fires the per-channel sends after subscribing", async () => { - const h = harness(1); + const h = harness([CH_A]); // The subscription is registered before the sends fire, so a frame arriving - // mid-send is never dropped. Awaiting sendStarted proves sends ran. + // mid-send is never dropped. Awaiting sendStarted proves sends ran. Uses a + // terminal-success frame (`switched`) since the provisional `sent` no longer + // settles the pick on its own. await h.sendStarted; - h.push(frame("sent")); + h.push(frame("switched")); 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); +test("awaitLiveSwitchOutcome with zero channels resolves pending at the timeout (no acks expected)", async () => { + // No active turns means an empty channel set: the success resolve only fires + // inside a frame callback keyed on an expected channel, so with no frames the + // timeout fallback is what settles it — to `"pending"`, since no positive + // terminal confirmed. This documents the degenerate path. + const h = harness([]); h.fireTimeout(); + assert.equal(await h.outcome, "pending"); +}); + +test("awaitLiveSwitchOutcome ignores a reconnect replay of an identical terminal frame", async () => { + // The observer relay requests a five-minute replay on reconnect, so the SAME + // terminal frame for one channel can arrive twice. A scalar count would treat + // the replay as a second channel's ack and settle a two-channel pick early. + const h = harness([CH_A, CH_B]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("switched", { channelId: CH_A })); + h.push(frame("switched", { channelId: CH_A })); // replay of the same frame + await drainMicrotasks(); + assert.equal( + settled, + false, + "a duplicated channel-A success must not stand in for channel B", + ); + + h.push(frame("switched", { channelId: CH_B })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome does not let one channel's duplicated success mask another channel's later failure", async () => { + // Two channels: A succeeds and its frame is replayed; B rejects late. A + // per-frame count would resolve "ok" on A's duplicate before B's failure and + // report a false success. Counting per distinct channel keeps the pick open + // for B, which fail-fasts to "failed". + const h = harness([CH_A, CH_B]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("switched", { channelId: CH_A })); + h.push(frame("switched", { channelId: CH_A })); // duplicate for A + await drainMicrotasks(); + assert.equal(settled, false, "A's duplicate must not complete the pick"); + + h.push(frame("failure", { channelId: CH_B })); + assert.equal(await h.outcome, "failed"); +}); + +test("awaitLiveSwitchOutcome ignores a stale result from an overlapping same-model operation", async () => { + // Two picks for the same model overlap: this operation is `req-new`; a prior + // `req-old` pick's terminal frame (same model, same channel) is still in + // flight. Correlating on requestId — not modelId — keeps the old result from + // settling the new pick. + const h = harness([CH_A], "req-new"); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push({ + type: "switch_model", + status: "switched", + requestId: "req-old", + channelId: CH_A, + }); + await drainMicrotasks(); + assert.equal(settled, false, "a prior same-model pick's result is inert"); + + h.push({ + type: "switch_model", + status: "switched", + requestId: "req-new", + channelId: CH_A, + }); + assert.equal(await h.outcome, "ok"); +}); + +// The channel guard must run BEFORE status handling so a negative frame is +// correlated by channel too — a misrouted or channel-less `failure`/ +// `unsupported_model` carrying this pick's requestId must not fail it. This is +// the false-failure mirror of the false-success class the positive-terminal +// channel count already guards. +test("awaitLiveSwitchOutcome ignores a failure frame from a foreign channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + // Same requestId, but a channel this pick never fired to: inert. + h.push(frame("failure", { channelId: "channel-foreign" })); + await drainMicrotasks(); + assert.equal( + settled, + false, + "a foreign-channel failure must not fail the pick", + ); + + h.push(frame("switched", { channelId: CH_A })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome ignores an unsupported_model frame from a foreign channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("unsupported_model", { channelId: "channel-foreign" })); + await drainMicrotasks(); + assert.equal( + settled, + false, + "a foreign-channel unsupported_model must not fail the pick", + ); + + h.push(frame("switched", { channelId: CH_A })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome ignores a failure frame that carries no channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("failure", { channelId: undefined })); + await drainMicrotasks(); + assert.equal(settled, false, "a channel-less failure must not fail the pick"); + + h.push(frame("switched", { channelId: CH_A })); + assert.equal(await h.outcome, "ok"); +}); + +test("awaitLiveSwitchOutcome ignores an unsupported_model frame that carries no channel", async () => { + const h = harness([CH_A]); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push(frame("unsupported_model", { channelId: undefined })); + await drainMicrotasks(); + assert.equal( + settled, + false, + "a channel-less unsupported_model must not fail the pick", + ); + + h.push(frame("switched", { channelId: CH_A })); assert.equal(await h.outcome, "ok"); }); diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts index d12261e596..83792dcdab 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.ts +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -4,57 +4,146 @@ 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. + * channel's result asynchronously over the observer relay. Two statuses + * fail-fast — any single frame rejects the whole pick immediately, without + * waiting for the other channels or the timeout: + * - `unsupported_model` → the target model isn't available for this agent. + * - `failure` → the adapter refused the switch (the session stays + * on its current model). + * Their causes differ, so they resolve to distinct outcomes (`"unsupported"` + * vs `"failed"`) the caller can message separately. + * + * `sent` is the busy-path PROVISIONAL ack: the switch was delivered to the + * in-flight turn, but the adapter isn't consulted until the requeued session + * runs. The real verdict lands later as a positive `switched` terminal (the + * deferred apply succeeded), a `failure`/`unsupported_model` frame (it didn't), + * so `sent` never settles the pick on its own — the subscription stays alive + * for the terminal frame. + * + * Success is only ever inferred from `switched` — the one status that means + * the model was APPLIED — which must arrive from every EXPECTED channel before + * resolving `"ok"`. The idle path emits it immediately; the busy path emits it + * when the requeued session applies the model. A busy turn routinely outlasts + * the fallback timeout, so the timeout NEVER resolves `"ok"` — it resolves + * `"pending"`: the switch was accepted and rides the requeued/next session, but + * we could not confirm the apply synchronously. The caller surfaces that + * truthfully rather than claiming a success that has not happened (and might + * yet be rejected). + * + * Two more statuses are non-delivery terminals — the harness never set the + * desired model and nothing applies later, so they can no more resolve `"ok"` + * than a `failure` can: + * - `turn_ending` → the control oneshot was already consumed (a prior + * cancel is ending the turn), so the switch can't land. + * - `no_active_turn` → neither an in-flight task nor an idle session-owning + * agent existed (a stale `activeTurns` snapshot between + * the picker read and the harness receipt). + * Both fail-fast to `"not_delivered"`, distinct from `"pending"` (which DID + * ride the requeued session): here the switch never landed at all. + * + * Any other status — a `sent` provisional ack, or an unknown future status — is + * inert: it is never counted as success. A new producer status that should + * settle the pick must add its own explicit branch. + * + * Two identity guards keep a stale or replayed frame from settling the wrong + * pick. The observer relay requests a five-minute replay on reconnect, so an + * old `control_result` for an earlier switch can re-arrive mid-pick: + * - `requestId` — an opaque per-pick correlator the harness echoes on every + * frame. Frames without a matching id are ignored, so a replayed result for + * a prior operation (which carried a different id, or none) is inert. + * - `channelId` — every frame, positive OR negative, must name a channel in + * the EXPECTED set before it can settle anything. A misrouted `failure` + * from a foreign channel (or a frame with no channel) can no more fail the + * pick than a foreign `switched` can satisfy it. Positive terminals are + * then counted once per DISTINCT expected channel, not once per frame, so + * two copies of one channel's `switched` can't satisfy a two-channel pick. * * 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, + requestId, + channelIds, 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; + /** Opaque per-pick id; frames without this exact id are ignored. */ + requestId: string; + /** Channels the switch was fired to — the distinct set to await. */ + channelIds: readonly 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) => { +}): Promise<"ok" | "unsupported" | "failed" | "not_delivered" | "pending"> { + const expected = new Set(channelIds); + const settled = new Promise< + "ok" | "unsupported" | "failed" | "not_delivered" | "pending" + >((resolve) => { let unsubscribe = () => {}; let cancelTimeout = () => {}; - let remaining = channelCount; - const finish = (outcome: "ok" | "unsupported") => { + const succeeded = new Set(); + const finish = ( + outcome: "ok" | "unsupported" | "failed" | "not_delivered" | "pending", + ) => { cancelTimeout(); unsubscribe(); resolve(outcome); }; - cancelTimeout = scheduleTimeout(() => finish("ok")); + // No positive terminal in time: the switch was accepted but its deferred + // apply hasn't confirmed. Resolve indeterminate — never a false "ok". + cancelTimeout = scheduleTimeout(() => finish("pending")); unsubscribe = subscribe((frame) => { - if (frame.type !== "switch_model" || frame.modelId !== modelId) { + // Two identity guards run BEFORE any status handling, so they scope + // every decision — positive AND negative — to THIS pick's channels: + // - requestId: a replayed result for a prior operation carries a + // different id (or none) and is ignored. + // - channelId: the frame must name an EXPECTED channel. A negative + // frame (`failure`/`unsupported_model`) misrouted from a foreign + // channel, or carrying no channel at all, must not fail this pick + // any more than a foreign positive frame may satisfy it. + if (frame.type !== "switch_model" || frame.requestId !== requestId) { + return; + } + if (!frame.channelId || !expected.has(frame.channelId)) { return; } if (frame.status === "unsupported_model") { - // Any single failure rejects the whole pick immediately. + // Model unavailable — reject the whole pick immediately. finish("unsupported"); return; } - // sent / switched / turn_ending — count as success for this channel. - remaining -= 1; - if (remaining <= 0) { + if (frame.status === "failure") { + // Adapter refused the switch — reject immediately. The session stays + // on its current model; distinct outcome so the caller can say why. + finish("failed"); + return; + } + if (frame.status === "turn_ending" || frame.status === "no_active_turn") { + // Non-delivery terminal: the harness never set the desired model and + // nothing applies later (`turn_ending` = the control oneshot was + // already consumed; `no_active_turn` = no in-flight task and no idle + // session-owning agent). Fail-fast, distinct from `"pending"` — here + // the switch never landed at all. + finish("not_delivered"); + return; + } + if (frame.status !== "switched") { + // Anything else — the provisional `sent` ack, or an unknown future + // status — is inert. Only `switched` (the model was APPLIED) counts. + // A busy `sent` is settled later by its own `switched`/`failure` + // terminal, or by the timeout resolving `"pending"`. + return; + } + // `switched` — the model was applied for this channel. Count each + // expected channel once: a duplicate frame for a channel already + // recorded (a replay, or a two-copy fan-out) is a no-op. + succeeded.add(frame.channelId); + if (succeeded.size >= expected.size) { finish("ok"); } }); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 88e8bba754..68fa290ad2 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -272,7 +272,7 @@ function appendAgentEvents( } if (added.length === 0) return null; - const sortedAdded = added.sort(compareObserverEvents); + const sortedAdded = [...added].sort(compareObserverEvents); const sorted = allAtEnd ? [...current, ...sortedAdded] : [...current, ...sortedAdded].sort(compareObserverEvents); @@ -468,9 +468,19 @@ function processLiveObserverEvents( // callbacks. Those callbacks historically observed their triggering frame // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. - const addedEvents = appendAgentEvents(agentPubkey, events); + // + // Dispatch iterates the ACCEPTED events, not the raw envelope: the observer + // relay requests a five-minute replay on reconnect, so an already-seen frame + // can re-arrive. `appendAgentEvents` drops those as duplicates and returns + // only the newly-accepted set; dispatching that set keeps a replayed + // `control_result` from re-settling a live model switch, and likewise + // prevents any other side-effect listener (latest-live tracking, management + // requests, session-config capture, lifecycle) from firing twice for one + // frame. Every such listener is a command or idempotent cache write — none + // depends on duplicate re-delivery — so deduping is strictly correct. + const accepted = appendAgentEvents(agentPubkey, events); - for (const parsed of events) { + for (const parsed of accepted ?? []) { // Track the latest-live-session-id per (agent, channel) on the live path. // Only set when the parsed event carries both a sessionId and channelId, // so we never attribute a session to the wrong channel. @@ -500,7 +510,9 @@ function processLiveObserverEvents( void putAgentSessionConfig(agentPubkey, parsed.payload); onSessionConfigCaptured?.(agentPubkey); } else if (parsed.kind === "control_result") { - dispatchControlResult(agentPubkey, parsed.payload); + // Thread the envelope's channelId into the frame so the ModelPicker can + // count a terminal switch result once per distinct channel. + dispatchControlResult(agentPubkey, parsed.payload, parsed.channelId); } else if (parsed.kind === "managed_agent_runtime_lifecycle") { void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch( (error) => { @@ -512,8 +524,8 @@ function processLiveObserverEvents( // Preserve the harness's envelope backpressure: retained state was committed // before specialized callbacks, but external-store subscribers publish once. - if (addedEvents) { - notifyListeners({ agentPubkey, events: addedEvents }); + if (accepted) { + notifyListeners({ agentPubkey, events: accepted }); } } @@ -639,7 +651,11 @@ function isControlResultFrame(payload: unknown): payload is ControlResultFrame { ); } -function dispatchControlResult(agentPubkey: string, payload: unknown) { +function dispatchControlResult( + agentPubkey: string, + payload: unknown, + channelId: string | null, +) { if (!isControlResultFrame(payload)) { return; } @@ -647,8 +663,13 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) { if (!subscribers) { return; } + // The channelId lives on the observer envelope, not the inner payload, so + // stamp it onto the frame here. Listeners (the ModelPicker) count a terminal + // switch result once per distinct channel; the envelope is the only place a + // late `control_result` carries its channel identity. + const frame: ControlResultFrame = { ...payload, channelId }; for (const subscriber of subscribers) { - subscriber(payload); + subscriber(frame); } } diff --git a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx index c2422466e4..b47cee5e3c 100644 --- a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx @@ -143,7 +143,10 @@ function AgentCardViewerContent({ toast.success(`Sent ${agentName}'s card.`); closeCardViewer(); } else if (sent === false) { - toast.error("Couldn’t send the card. Try again."); + toast.error( + sendController.getCurrentError() ?? + "Couldn’t send the card. Try again.", + ); } } diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 046bd13c47..e5f3a74530 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -289,6 +289,30 @@ function ProfileConfigSection({ ); } +/** + * #3493: caveat shown when the surface was read from a user-set + * `CLAUDE_CONFIG_DIR`. Claude Code keys its stored login to the config-dir + * path, so a custom dir maps to a fresh Keychain namespace — the agent starts + * logged out unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set to match the + * default login. + */ +function ClaudeConfigDirNotice() { + return ( +
+

+ ⚠ Custom CLAUDE_CONFIG_DIR{" "} + active — config is read from that directory. Claude Code keys its login + to the config-dir path, so a custom dir creates a new Keychain + namespace. The agent will need to re-authenticate unless you also set{" "} + + CLAUDE_SECURESTORAGE_CONFIG_DIR + {" "} + to match your default login. +

+
+ ); +} + export function AgentConfigPanel({ advancedMode = "collapsed", onEdit, @@ -355,7 +379,9 @@ export function AgentConfigSurfaceRows({ }: AgentConfigSurfaceRowsProps) { const [advancedOpen, setAdvancedOpen] = React.useState(false); - const { normalized, advanced, extensions, runtimeId } = data; + const { normalized, advanced, extensions, runtimeId, sources } = data; + const mcpConfigFilePath = sources.mcpConfigFilePath; + const claudeConfigDirCustom = data.claudeConfigDirCustom ?? false; const normalizedEntries = ( Object.entries(normalized) as [ @@ -414,6 +440,7 @@ export function AgentConfigSurfaceRows({ > @@ -430,6 +457,8 @@ export function AgentConfigSurfaceRows({ ))} ) : null} + + {claudeConfigDirCustom ? : null}
); } @@ -457,6 +486,7 @@ export function AgentConfigSurfaceRows({ @@ -485,6 +515,8 @@ export function AgentConfigSurfaceRows({ ) : null} ) : null} + + {claudeConfigDirCustom ? : null} ); } diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f7ee098833..62d85d385d 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -25,6 +25,7 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; +import { EffortPickerField } from "./EffortPickerField"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { ADVANCED_FIELDS_MOTION_TRANSITION, @@ -267,10 +268,10 @@ export function AgentInstanceEditDialog({ return runtimeSupportsLlmProviderSelection(matched?.id ?? ""); }, [runtimes, originalAgentCommand]); - // The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime - // (that is what runs once the override is cleared, not the current override). - // Falls back to dual-match (command path, then id) when no persona or its runtime is unset. - // This single prospective id feeds BOTH the block-save gate and submit so they always agree. + // The runtime id active after submit — the single prospective id feeding BOTH + // the block-save gate and submit so they always agree. Inheriting resolves + // from the LINKED PERSONA's runtime (what runs once the override is cleared), + // falling back to dual-match (command path, then id) when no persona. const prospectiveRuntimeId = React.useMemo(() => { if (!inheritHarness) { return selectedRuntime?.id ?? selectedRuntimeId; @@ -425,11 +426,10 @@ export function AgentInstanceEditDialog({ selectedRuntime, }); - // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. - // The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating. - // D2/D3: the top-level API key owns display, while the readiness gate keeps - // the complete required-key list. The effective snapshot covers persona - // inheritance during an instance inherit transition. + // D2/D3: the top-level API key owns display while the readiness gate keeps the + // complete required-key list; advancedRequiredEnvKeys drives EnvVarsEditor + // display only. The effective snapshot covers persona inheritance during an + // instance inherit transition. const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider); const personaSatisfied = providerApiKeyEnvVar != null && @@ -693,11 +693,9 @@ export function AgentInstanceEditDialog({ : normalizedModel !== (agent.model ?? null) ? normalizedModel : undefined, - // Tri-state provider persistence keyed on providerRuntimeCapability: - // "capable" → persist: value if changed, omit if unchanged. - // "locked" → clear: send null if provider was set, else omit. - // "unknown" → omit always (never send null for a transient state). - // llmProviderFieldVisible is for UX visibility only; not used here. + // Tri-state provider persistence keyed on providerRuntimeCapability + // (see the classification comment above for the capable/locked/unknown + // contract). llmProviderFieldVisible is UX visibility only; not used here. provider: linkedPersona != null ? undefined @@ -1128,6 +1126,8 @@ export function AgentInstanceEditDialog({ ) : null} + + setAiDefaultsOpen(true)} triggerRef={aiDefaultsTriggerRef} diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx new file mode 100644 index 0000000000..a06f17ac11 --- /dev/null +++ b/desktop/src/features/agents/ui/EffortPickerField.tsx @@ -0,0 +1,81 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; +import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types"; +import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions"; +import { + effortPickerState, + effortSelectionToPersistedValue, +} from "./effortPicker"; +import { PersonaDropdownField } from "./PersonaDropdownField"; + +/** + * Thinking-effort write control for the edit dialog (B5, v4 direct-write). + * + * Local-only by construction: the write calls `persistAgentEffortLevel`, which + * the Rust command rejects for non-local backends (remote effort is set at + * deploy time via `policy_env`). So the control renders only for a local + * backend AND once the adapter has advertised a `thought_level` configId + * (discovered from the running session — absent pre-first-session and for + * runtimes/models without effort support). The read-only configured-vs-running + * two-facts display lives in `AgentConfigPanel`; this is the write control. + * + * Direct-write: each selection persists immediately and invalidates the config + * surface so the panel's canonical tier reflects the new next-spawn value. + */ +export function EffortPickerField({ + agent, + config, +}: { + agent: ManagedAgent; + config: RuntimeConfigSurface | undefined; +}) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (level: string | null) => + persistAgentEffortLevel(agent.pubkey, level), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }), + }); + const { visible, options, selectValue } = effortPickerState({ + backend: agent.backend, + effortConfigId: config?.effortConfigId, + effortOptions: config?.effortOptions, + currentEffort: config?.normalized.thinkingEffort?.value ?? null, + }); + + if (!visible) { + return null; + } + + return ( +
+ + + mutation.mutate(effortSelectionToPersistedValue(value)) + } + options={options} + placeholder="Adapter default" + value={selectValue} + /> +

+ Applied at the next session start. +

+ {mutation.error instanceof Error ? ( +

{mutation.error.message}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/McpServersSection.test.mjs b/desktop/src/features/agents/ui/McpServersSection.test.mjs new file mode 100644 index 0000000000..526acbca5e --- /dev/null +++ b/desktop/src/features/agents/ui/McpServersSection.test.mjs @@ -0,0 +1,90 @@ +/** + * #3493 provenance: the MCP servers section must attribute its entries to the + * ACTUAL config file the reader read — which, under a custom CLAUDE_CONFIG_DIR, + * is the isolated `/.claude.json`, not the default `~/.claude.json`. + * + * Before this fix `mcpConfigFilePath` was carried on the DTO but no component + * consumed it, so the panel listed the correct servers with no file + * attribution at all. These pin the rendered contract. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { + McpServersSection, + mcpConfigFileCaption, +} from "./McpServersSection.tsx"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const CLAUDE_MCP = [{ name: "sentinel", kind: "stdio", enabled: true }]; + +test("mcpConfigFileCaption_customPath_returnsFileAttribution", () => { + assert.equal( + mcpConfigFileCaption("/tmp/iso/.claude.json"), + "From config file (/tmp/iso/.claude.json)", + ); +}); + +test("mcpConfigFileCaption_nullPath_returnsNull", () => { + assert.equal(mcpConfigFileCaption(null), null); + assert.equal(mcpConfigFileCaption(undefined), null); +}); + +test("McpServersSection_customConfigDir_rendersIsolatedFilePath", async () => { + const { render } = await import("@testing-library/react"); + const React = await import("react"); + + const { container } = render( + React.createElement(McpServersSection, { + extensions: CLAUDE_MCP, + mcpConfigFilePath: "/tmp/iso/.claude.json", + runtimeId: "claude", + variant: "compact", + }), + ); + + assert.match(container.textContent, /sentinel/); + assert.match( + container.textContent, + /From config file \(\/tmp\/iso\/\.claude\.json\)/, + ); +}); + +test("McpServersSection_noConfigPath_omitsFileAttribution", async () => { + const { render } = await import("@testing-library/react"); + const React = await import("react"); + + const { container } = render( + React.createElement(McpServersSection, { + extensions: CLAUDE_MCP, + mcpConfigFilePath: null, + runtimeId: "claude", + variant: "compact", + }), + ); + + assert.match(container.textContent, /sentinel/); + assert.doesNotMatch(container.textContent, /From config file/); +}); diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx index f92f77a392..db3de3b200 100644 --- a/desktop/src/features/agents/ui/McpServersSection.tsx +++ b/desktop/src/features/agents/ui/McpServersSection.tsx @@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn"; type McpServersSectionProps = { extensions: ExtensionEntry[]; runtimeId: string | null; + mcpConfigFilePath?: string | null; variant?: "compact" | "profile"; buzzAgentSlot?: React.ReactNode; }; @@ -19,9 +20,19 @@ export function shouldRenderMcpServers( return runtimeId === "buzz-agent" || extensions.length > 0; } +// #3493: the servers are read from the isolated `.claude.json` under a custom +// `CLAUDE_CONFIG_DIR`. Attribute them to that actual file so the panel never +// implies the default `~/.claude.json` when isolation is in effect. +export function mcpConfigFileCaption( + mcpConfigFilePath: string | null | undefined, +): string | null { + return mcpConfigFilePath ? `From config file (${mcpConfigFilePath})` : null; +} + export function McpServersSection({ buzzAgentSlot, extensions, + mcpConfigFilePath, runtimeId, variant = "compact", }: McpServersSectionProps) { @@ -31,6 +42,8 @@ export function McpServersSection({ return null; } + const fileCaption = mcpConfigFileCaption(mcpConfigFilePath); + return (
)} + + {extensions.length > 0 && fileCaption ? ( +

+ {fileCaption} +

+ ) : null}
); } diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 863cd85119..0bc6f9646a 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -109,26 +109,39 @@ export function ModelPicker({ }, [configSurface]); // Send a live `switch_model` frame to each channel the agent is working in - // and wait for the harness to acknowledge. Any single `unsupported_model` - // result rejects the whole pick immediately; all other statuses must arrive - // from every channel before resolving success. + // and wait for the harness to acknowledge. A single `unsupported_model` + // (model unavailable) or `failure` (adapter refused) result rejects the whole + // pick immediately. The busy-path `sent` ack is provisional (the adapter + // isn't consulted until the requeued session); success is confirmed only by a + // real positive terminal frame from every channel, and if none arrives before + // the timeout the pick resolves `"pending"` (accepted, apply deferred). const sendLiveSwitch = React.useCallback( (modelId: string) => { const channelIds = activeTurns.map((turn) => turn.channelId); + // Opaque per-pick correlator. The harness echoes it on the immediate ack + // and the late terminal frame, so a five-minute reconnect replay of an + // earlier pick's result cannot settle this one. + const requestId = crypto.randomUUID(); return awaitLiveSwitchOutcome({ - channelCount: channelIds.length, - modelId, + requestId, + channelIds, subscribe: (listener) => subscribeControlResults(agent.pubkey, listener), sendSwitches: async () => { await Promise.all( channelIds.map((channelId) => - switchManagedAgentModel(agent.pubkey, channelId, modelId), + switchManagedAgentModel( + agent.pubkey, + channelId, + modelId, + requestId, + ), ), ); }, - // No reply in time: treat as sent. The override still rides the - // requeued/next session; we just can't confirm synchronously. + // No positive terminal in time: resolve `"pending"`. The override still + // rides the requeued/next session; we just can't confirm synchronously, + // and must not claim a success that hasn't happened. scheduleTimeout: (onTimeout) => { const timeout = window.setTimeout(onTimeout, 8_000); return () => window.clearTimeout(timeout); @@ -148,6 +161,31 @@ export function ModelPicker({ toast.error("That model isn't available for this agent."); return; } + if (outcome === "failed") { + toast.error( + "Couldn't switch models — the agent kept its current model.", + ); + return; + } + if (outcome === "not_delivered") { + // The switch never reached a session: the turn was already ending, or + // no active turn remained by the time the harness received it. Nothing + // was applied and nothing rides a later session — tell the truth. + toast.error( + "Couldn't switch models — the agent wasn't running a turn to switch.", + ); + return; + } + if (outcome === "pending") { + // The switch was accepted but its apply is deferred to the next + // session (the agent is mid-turn) and didn't confirm before the + // fallback timeout. Tell the truth instead of claiming success. + toast.info( + "Model switch pending — applies when the current turn finishes.", + ); + onModelChanged?.(); + return; + } toast.success("Model switched for this session."); onModelChanged?.(); return; diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 5cf4f9ea3b..13eae7971b 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -391,7 +391,10 @@ export function SnapshotShareDialog({ toast.success(`Sent a copy of ${displayName}`); onOpenChange(false); } else if (sent === false) { - toast.error(`Couldn’t send ${itemLabel}. Try again.`); + toast.error( + snapshotSendController.getCurrentError() ?? + `Couldn’t send ${itemLabel}. Try again.`, + ); } } diff --git a/desktop/src/features/agents/ui/effortPicker.test.mjs b/desktop/src/features/agents/ui/effortPicker.test.mjs new file mode 100644 index 0000000000..28c22ec9d9 --- /dev/null +++ b/desktop/src/features/agents/ui/effortPicker.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EFFORT_DEFAULT_DROPDOWN_VALUE, + effortPickerState, + effortSelectionToPersistedValue, +} from "./effortPicker.ts"; + +const localBackend = { type: "local" }; +const providerBackend = { type: "provider", id: "openai", config: {} }; +const options = [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, +]; + +test("effort picker renders for a local backend with a discovered configId", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.equal(state.visible, true); +}); + +test("effort picker is hidden for a provider backend even when a configId exists", () => { + const state = effortPickerState({ + backend: providerBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "high", + }); + assert.equal(state.visible, false); +}); + +test("effort picker is hidden for a local backend without a discovered configId", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: undefined, + effortOptions: undefined, + currentEffort: null, + }); + assert.equal(state.visible, false); +}); + +test("options lead with the adapter-default sentinel then adapter values", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.deepEqual(state.options, [ + { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE }, + { label: "Low", value: "low" }, + { label: "High", value: "high" }, + ]); +}); + +test("option label falls back to the raw value when displayName is absent", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: [{ value: "medium" }], + currentEffort: null, + }); + assert.deepEqual(state.options[1], { label: "medium", value: "medium" }); +}); + +test("current effort preselects the matching option", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "high", + }); + assert.equal(state.selectValue, "high"); +}); + +test("an unknown current effort falls back to the adapter-default sentinel", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "extreme", + }); + assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE); +}); + +test("a null current effort selects the adapter-default sentinel", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE); +}); + +test("the sentinel selection persists as null (clear to adapter default)", () => { + assert.equal( + effortSelectionToPersistedValue(EFFORT_DEFAULT_DROPDOWN_VALUE), + null, + ); +}); + +test("a concrete selection persists as its explicit effort level", () => { + assert.equal(effortSelectionToPersistedValue("high"), "high"); +}); diff --git a/desktop/src/features/agents/ui/effortPicker.ts b/desktop/src/features/agents/ui/effortPicker.ts new file mode 100644 index 0000000000..515355e4ad --- /dev/null +++ b/desktop/src/features/agents/ui/effortPicker.ts @@ -0,0 +1,71 @@ +import type { + AcpConfigOptionValue, + ManagedAgentBackend, +} from "@/shared/api/types"; +import type { PersonaDropdownOption } from "./agentConfigOptions"; + +/** + * Sentinel dropdown value for "no explicit effort" — reverts the agent to the + * adapter default at the next spawn. Distinct from any adapter option value. + */ +export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__"; + +/** + * Pure gating + option compute for the effort write control in the edit dialog. + * + * The picker is a LOCAL-only, direct-write control: it calls + * `persistAgentEffortLevel`, which the Rust command rejects for non-local + * backends (remote effort is set at deploy time via `policy_env`). So the UI + * must not offer it for a provider backend, and there's nothing to pick until + * the adapter has advertised a `thought_level` config option (discovered from + * the running session — `effortConfigId` is absent pre-first-session and for + * runtimes/models that don't support effort). + * + * `visible` is the single gate the dialog renders on: local backend AND a + * discovered `effortConfigId`. + */ +export function effortPickerState({ + backend, + effortConfigId, + effortOptions, + currentEffort, +}: { + backend: ManagedAgentBackend; + effortConfigId: string | undefined; + effortOptions: readonly AcpConfigOptionValue[] | undefined; + currentEffort: string | null; +}): { + visible: boolean; + options: PersonaDropdownOption[]; + selectValue: string; +} { + const visible = backend.type === "local" && effortConfigId !== undefined; + + const options: PersonaDropdownOption[] = [ + { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE }, + ...(effortOptions ?? []).map((option) => ({ + label: option.displayName ?? option.value, + value: option.value, + })), + ]; + + // Preselect the currently-configured effort when it maps to a known option; + // otherwise fall back to the adapter-default sentinel (also the null case). + const trimmed = currentEffort?.trim() ?? ""; + const selectValue = + trimmed.length > 0 && + (effortOptions ?? []).some((option) => option.value === trimmed) + ? trimmed + : EFFORT_DEFAULT_DROPDOWN_VALUE; + + return { visible, options, selectValue }; +} + +/** + * Map a dropdown selection back to the value persisted via + * `persistAgentEffortLevel`: the sentinel clears effort (null → adapter + * default), any other value is the explicit effort level. + */ +export function effortSelectionToPersistedValue(value: string): string | null { + return value === EFFORT_DEFAULT_DROPDOWN_VALUE ? null : value; +} diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts index e14481b750..d7e4daca2c 100644 --- a/desktop/src/features/agents/ui/useSnapshotSendController.ts +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -350,6 +350,12 @@ export type UseSnapshotSendControllerResult = { /** Relay moderation identity to exclude from the people picker. */ relaySelfPubkey: string | null; state: SnapshotSendState; + /** + * Read the latest error synchronously — right after `beginSend` resolves the + * render-captured `state.error` is stale until the next commit, so callers + * that toast on failure must read through here. + */ + getCurrentError: () => string | null; /** * Execute destination creation plus prepare → encode → upload → send behind * one concurrency guard. A second call while the first is in flight returns @@ -390,6 +396,15 @@ export function useSnapshotSendController( error: null, }); + // Mirror `state` into a ref so callers can read the latest error + // synchronously right after `beginSend` resolves — the render-captured + // `state` in their closure is stale until the next render commits. + const stateRef = React.useRef(state); + const commitState = React.useCallback((next: SnapshotSendState) => { + stateRef.current = next; + setState(next); + }, []); + // Single-concurrency guard covering the full encode → upload → send action. // Stored in a ref so it survives re-renders without triggering effects. const guardRef = React.useRef(createSendGuard()); @@ -417,7 +432,7 @@ export function useSnapshotSendController( checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), sendFn: (args) => sendMutation.mutateAsync(args), - setStateFn: setState, + setStateFn: commitState, buildMessageFn: (descriptor) => { const message = buildOutgoingMessage("", [descriptor]); return attachmentLabel?.trim() @@ -430,15 +445,15 @@ export function useSnapshotSendController( : message; }, }), - setState, + commitState, ); } const reset = React.useCallback(() => { if (!guardRef.current.inFlight) { - setState({ phase: "idle", error: null }); + commitState({ phase: "idle", error: null }); } - }, []); + }, [commitState]); return { isDmSafetyReady: @@ -447,6 +462,7 @@ export function useSnapshotSendController( relaySelfQuery.status === "success"), relaySelfPubkey: relaySelfQuery.data ?? null, state, + getCurrentError: () => stateRef.current.error, beginSend, reset, }; diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 69e511fb40..512ee6899f 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({

- +
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a96103..cfa84f02e3 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -10,7 +10,12 @@ import { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -26,7 +31,6 @@ type BotActivityBarProps = { variant?: "toolbar" | "inline"; }; -const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; @@ -106,7 +110,7 @@ export function BotActivityComposerAction({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer]); const closeWithDelay = React.useCallback(() => { diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index cd530e6908..4d6f2d258b 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -39,6 +39,12 @@ import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { EditCommunityDialog } from "./EditCommunityDialog"; +// Community actions is a responsive navigation submenu, not an informational +// disclosure. Keep its short hover dwell explicit rather than inheriting the +// shared 500 ms Popover delay intended to prevent incidental inspection UI. +const PROFILE_MENU_HOVER_OPEN_DELAY_MS = 80; +const PROFILE_MENU_HOVER_CLOSE_DELAY_MS = 160; + const CONNECTION_STATE_LABEL: Record = { idle: "Not connected", connecting: "Connecting…", @@ -128,7 +134,9 @@ export function CommunitySwitcher({ clearProfileMenuHoverTimer(); profileMenuHoverTimer.current = window.setTimeout( () => setDropdownOpen(nextOpen), - nextOpen ? 80 : 160, + nextOpen + ? PROFILE_MENU_HOVER_OPEN_DELAY_MS + : PROFILE_MENU_HOVER_CLOSE_DELAY_MS, ); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9bb593087d..4b64ad10e0 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -622,7 +622,7 @@ function InboxMessageDetailPane({
- +
{canOpenChannel && contextChannelId ? ( diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b83a19a167..f2d4421081 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -433,14 +433,14 @@ export function InboxListPane({
{timestampLabel} @@ -138,7 +139,7 @@ export function InboxMessageRow({ ) : null}
-

+

{hoverTimestampLabel}

@@ -207,14 +208,20 @@ export function InboxMessageRow({
{isContinuation ? null : ( -
+
- + {message.authorLabel} @@ -240,10 +247,13 @@ export function InboxMessageRow({
)} -
+
{children}
@@ -93,7 +94,7 @@ export function MessageAuthorText({ return ( { if (reaction.users.length === 0) return; clearTimers(); - openTimeout.current = setTimeout(() => setOpen(true), 200); + openTimeout.current = setTimeout( + () => setOpen(true), + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + ); }, [reaction.users.length, clearTimers]); const scheduleClose = React.useCallback(() => { diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index de496836c1..fd5be7d9a8 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -295,7 +295,7 @@ export const MessageRow = React.memo( message.body, message.tags, ); - const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5"; + const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body"; const { nonDmChannelNames: channelNames } = useChannelNavigation(); @@ -411,7 +411,7 @@ export const MessageRow = React.memo( - ))} - + ({ + value: mode, + label, + Icon, + }))} + testId="appearance-color-mode-control" + value={selectedMode} + /> @@ -808,6 +790,7 @@ function ThemeSettingsCard() { data-testid="appearance-preferences-card" title="Preferences" > + diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1c86ac4c36..e6e24ce27b 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -16,10 +16,14 @@ import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; -import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverAnchor, + PopoverContent, +} from "@/shared/ui/popover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_DELAY_MS = 180; const ACTIVITY_POPOVER_MOTION_STYLE = { "--tw-enter-scale": "1", @@ -310,7 +314,7 @@ export function ChannelActivityPopover({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer, hasContent]); const openImmediately = React.useCallback(() => { if (!hasContent) return; diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 5394065b19..12cb2ea9d3 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -374,7 +374,7 @@ export function CommunityRail({ return (
@@ -707,8 +709,7 @@ const SidebarGroupAction = React.forwardRef< data-sidebar="group-action" className={cn( "absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", + MOBILE_ACTION_HIT_AREA, "group-data-[collapsible=icon]:hidden", className, )} @@ -853,8 +854,7 @@ const SidebarMenuAction = React.forwardRef< data-sidebar="menu-action" className={cn( "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", + MOBILE_ACTION_HIT_AREA, "peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=lg]/menu-button:top-2.5", diff --git a/desktop/src/shared/ui/tooltip.tsx b/desktop/src/shared/ui/tooltip.tsx index afa58c753e..6af9e48188 100644 --- a/desktop/src/shared/ui/tooltip.tsx +++ b/desktop/src/shared/ui/tooltip.tsx @@ -3,7 +3,23 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { cn } from "@/shared/lib/cn"; -const TooltipProvider = TooltipPrimitive.Provider; +// Hover-only disclosure should require deliberate pointer dwell. Disabling Radix's +// skip-delay grace prevents tooltips from cascading open while the pointer moves +// across adjacent controls. Callers may override both values for a proven case. +const DEFAULT_TOOLTIP_DELAY_MS = 500; +const DEFAULT_TOOLTIP_SKIP_DELAY_MS = 0; + +const TooltipProvider = ({ + delayDuration = DEFAULT_TOOLTIP_DELAY_MS, + skipDelayDuration = DEFAULT_TOOLTIP_SKIP_DELAY_MS, + ...props +}: React.ComponentProps) => ( + +); const Tooltip = TooltipPrimitive.Root; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1aa98ca4a7..1f7b147749 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -411,6 +411,10 @@ type E2eConfig = { nostrBindSignDelayMs?: number; /** Reject successive mock WebSocket connect attempts, then resume. */ websocketConnectErrors?: string[]; + /** Deliver AUTH synchronously, before the mock connect command resolves. */ + websocketAuthBeforeConnectResolves?: boolean; + /** Stall the first AUTH signing command forever; later attempts complete. */ + stallFirstAuthSigning?: boolean; stallWebsocketSends?: boolean; userSearchDelayMs?: number; // NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for @@ -1813,6 +1817,12 @@ function buildMockConfigSurface(pubkey: string): { sources: Record; } { // Goose running — mixed origins, override on model + // The `writeVia` payloads below are camelCase because that is what the + // backend emits — pinned by `wire_format_matches_typescript_contract` in + // `desktop/src-tauri/src/managed_agents/config_bridge/types.rs`. This mock + // agreed with `api/types.ts` while the real serializer emitted `env_key` / + // `config_id` / `config_key`, so a test against it certified a contract + // nothing produced. Change these only alongside that Rust test. const gooseSurface = { runtimeId: "goose", runtimeLabel: "Goose", @@ -3062,6 +3072,7 @@ const mockAuthResponses: Array<{ success: boolean; message: string }> = []; const mockChannelHistoryCloses: string[] = []; let mockWebsocketUnavailable = false; const relayWebsocketConnectAttemptStarts: number[] = []; +let mockAuthSigningAttempts = 0; let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); @@ -9757,9 +9768,14 @@ async function connectMockSocket(args: { onMessage: unknown }) { subscriptions: new Map(), }); - window.setTimeout(() => { + if (getConfig()?.mock?.websocketAuthBeforeConnectResolves) { sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]); - }, 0); + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } else { + window.setTimeout(() => { + sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]); + }, 0); + } return wsId; } @@ -10248,6 +10264,7 @@ export function maybeInstallE2eTauriMocks() { mockAuthResponses.length = 0; mockChannelHistoryCloses.length = 0; relayWebsocketConnectAttemptStarts.length = 0; + mockAuthSigningAttempts = 0; deferredSendMessageLiveEchoes.length = 0; deferredLinkPreviewMetadataQueue = []; deferredLinkPreviewUploadQueue = []; @@ -13131,6 +13148,13 @@ export function maybeInstallE2eTauriMocks() { case "nip44_decrypt_from_self": return (payload as { ciphertext: string }).ciphertext; case "create_auth_event": + mockAuthSigningAttempts++; + if ( + getConfig()?.mock?.stallFirstAuthSigning && + mockAuthSigningAttempts === 1 + ) { + return new Promise(() => {}); + } if (identity) { return JSON.stringify( await signWithIdentity(identity, { diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 8905fc16ce..07d00b0db9 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -3,18 +3,46 @@ export default { theme: { extend: { // Sub-`text-xs` ramp for meta text (timestamps, count badges, tracking - // labels) and tiny glyphs. Defined in rem so Cmd +/- zoom — which scales - // the root font-size — keeps scaling them. Do NOT reintroduce - // arbitrary `text-[…rem]` / `text-[…px]` literals; the px-text guard - // rejects them. Stock scale picks up from here: xs (12px), sm (14px)… + // labels) and tiny glyphs. These follow the virtual typography rem so + // preferences and Cmd +/- scale text without changing layout geometry. + // Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals; + // the px-text guard rejects them. Stock scale picks up from xs. fontSize: { - "2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges) - "3xs": "0.5rem", // 8px — tiny glyphs / micro labels - badge: "0.625rem", // 10px — compact status badges - // 40px — onboarding page titles (tightened tracking for large display type) - title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }], - // 36px — the backup-step private key, shown large in monospace - "nsec-key": ["2.25rem", { lineHeight: "1.3" }], + "2xs": "calc(var(--buzz-type-rem) * 0.6875)", // 11px at 16px type rem + "3xs": "calc(var(--buzz-type-rem) * 0.5)", // 8px at 16px type rem + badge: "calc(var(--buzz-type-rem) * 0.625)", // 10px at 16px type rem + // Shared channel, DM, thread, and composer type. Variables keep app-wide + // font size and keyboard zoom consistent without branching components. + message: [ + "var(--conversation-message-font-size)", + { lineHeight: "var(--conversation-message-line-height)" }, + ], + "message-timestamp": [ + "var(--conversation-timestamp-font-size)", + { lineHeight: "var(--conversation-timestamp-line-height)" }, + ], + // 40px at the 16px type rem — onboarding page titles. + title: [ + "calc(var(--buzz-type-rem) * 2.5)", + { lineHeight: "1.15", letterSpacing: "-0.02em" }, + ], + // 36px at the 16px type rem — backup-step private key. + "nsec-key": [ + "calc(var(--buzz-type-rem) * 2.25)", + { lineHeight: "1.3" }, + ], + }, + lineHeight: { + // Keep fixed Tailwind line-height utilities in the typography scale so + // Cmd +/- cannot enlarge glyphs inside an unchanged line box. Single- + // line surfaces keep their existing truncate/overflow behavior. + 3: "calc(var(--buzz-type-rem) * 0.75)", + 4: "var(--buzz-type-rem)", + 5: "calc(var(--buzz-type-rem) * 1.25)", + 6: "calc(var(--buzz-type-rem) * 1.5)", + 7: "calc(var(--buzz-type-rem) * 1.75)", + 8: "calc(var(--buzz-type-rem) * 2)", + "message-author": "var(--conversation-author-line-height)", }, boxShadow: { "content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)", @@ -36,6 +64,10 @@ export default { }, spacing: { 4.5: "1.125rem", + "conversation-body": "var(--conversation-body-gap)", + "conversation-list": "var(--conversation-list-item-gap)", + "conversation-paragraph": "var(--conversation-paragraph-gap)", + "conversation-row": "var(--conversation-row-padding-block)", }, fontFamily: { sans: [ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index e191d29304..b81c5889f3 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -747,6 +747,8 @@ test("moves agent actions into an overflow menu in a narrow view", async ({ }); await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + // The app-wide default renders text-base at 16px with Tailwind's 1.5 + // line-height ratio, producing a 24px one-line scroll height. await expect( page.getByText("Set up and manage your agents.", { exact: true }), ).toHaveJSProperty("scrollHeight", 24); @@ -2287,7 +2289,9 @@ test("people sharing blocks a timeout before encoding or upload", async ({ }); await page.getByTestId("persona-share-send").click(); - await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible(); + await expect( + page.getByText("You are currently timed out and cannot send messages."), + ).toBeVisible(); const commands = await readAgentShareCommands(page); expect( @@ -2332,7 +2336,11 @@ test("people sharing rechecks destination eligibility after encoding", async ({ return testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); }); - await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible({ + await expect( + page.getByText( + "The selected destination is no longer available. Please pick another.", + ), + ).toBeVisible({ timeout: 5_000, }); const commands = await readAgentShareCommands(page); diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts index 2bcd36908e..0c01bfd660 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -8,6 +8,8 @@ const THEME_STORAGE_KEY = "buzz-theme"; const GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background"; const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity"; const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab"; +const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity"; +const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize"; const MOCK_PUBKEY = "deadbeef".repeat(8); const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -617,7 +619,7 @@ test("appearance groups theme and preferences into labeled rows", async ({ (lightModeButtonBox.x + lightModeButtonBox.width / 2), ), ).toBeLessThanOrEqual(0.5); - await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.25s"); + await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.2s"); await themeCard.getByTestId("appearance-mode-dark").click(); await waitForAnimations(page); @@ -639,6 +641,512 @@ test("appearance groups theme and preferences into labeled rows", async ({ ).toBeLessThanOrEqual(0.5); }); +test("app font size and conversation density apply independently", async ({ + page, +}) => { + await seedTheme(page, "buzz"); + await installMockBridge(page); + await openAppearance(page, "light"); + + const root = page.locator("html"); + const densityControl = page.getByTestId("conversation-density-control"); + const compact = page.getByTestId("conversation-density-compact"); + const comfortable = page.getByTestId("conversation-density-comfortable"); + const spacious = page.getByTestId("conversation-density-spacious"); + const densityIndicator = page.getByTestId( + "conversation-density-control-indicator", + ); + const fontSizeControl = page.getByTestId("font-size-control"); + const smaller = page.getByTestId("font-size-smaller"); + const defaultSize = page.getByTestId("font-size-default"); + const larger = page.getByTestId("font-size-larger"); + const fontSizeIndicator = page.getByTestId("font-size-control-indicator"); + const preview = page.getByTestId("conversation-preview"); + const previewSurface = page.getByTestId("conversation-preview-surface"); + const previewContent = page.getByTestId("conversation-preview-content"); + const previewChip = preview.getByText("Preview"); + const firstPreviewMessage = previewSurface.locator("article").first(); + const previewMessage = preview.getByText( + "The revised conversation layout is ready to review.", + ); + const previewTimestamp = preview.getByText("9:41"); + const densityDescription = page + .getByTestId("conversation-density-row") + .locator("[data-settings-subcopy]"); + const fontSizeDescription = page + .getByTestId("font-size-row") + .locator("[data-settings-subcopy]"); + const readScale = () => + root.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + authorLineHeight: Number.parseFloat( + style.getPropertyValue("--conversation-author-line-height"), + ), + bodyGap: Number.parseFloat( + style.getPropertyValue("--conversation-body-gap"), + ), + fontSize: style.getPropertyValue("--conversation-message-font-size"), + lineHeight: style.getPropertyValue( + "--conversation-message-line-height", + ), + paragraphGap: Number.parseFloat( + style.getPropertyValue("--conversation-paragraph-gap"), + ), + rowPadding: Number.parseFloat( + style.getPropertyValue("--conversation-row-padding-block"), + ), + timestampFontSize: style.getPropertyValue( + "--conversation-timestamp-font-size", + ), + timestampLineHeight: Number.parseFloat( + style.getPropertyValue("--conversation-timestamp-line-height"), + ), + }; + }); + const readSettingsScale = () => + page.getByTestId("conversation-density-row").evaluate((element) => { + const rowStyle = window.getComputedStyle(element); + const label = element.querySelector("p"); + if (!label) throw new Error("Conversation density label is missing"); + const labelStyle = window.getComputedStyle(label); + return { + fontSize: labelStyle.fontSize, + lineHeight: labelStyle.lineHeight, + minHeight: rowStyle.minHeight, + paddingBlock: rowStyle.paddingTop, + }; + }); + const readPreviewTimestampScale = () => + previewTimestamp.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }); + const readSettingsChromeScale = () => + Promise.all([ + page + .getByRole("heading", { name: "Appearance" }) + .evaluate((element) => window.getComputedStyle(element).fontSize), + page + .getByTestId("settings-nav-appearance") + .evaluate((element) => window.getComputedStyle(element).fontSize), + page + .getByRole("heading", { name: "Preferences" }) + .evaluate((element) => window.getComputedStyle(element).fontSize), + ]); + + await expect( + page.getByRole("group", { name: "Conversation density" }), + ).toBeVisible(); + await expect(page.getByRole("group", { name: "Font size" })).toBeVisible(); + await expect(densityControl).toHaveAccessibleName("Conversation density"); + await expect(fontSizeControl).toHaveAccessibleName("Font size"); + await expect(comfortable).toHaveText("Comfy"); + await expect(defaultSize).toHaveText("Default"); + await expect(preview).toContainText("Preview"); + await expect(preview).not.toContainText("Message #design"); + await expect(comfortable).toHaveAttribute("aria-pressed", "true"); + await expect(defaultSize).toHaveAttribute("aria-pressed", "true"); + await expect(densityDescription).toHaveText( + "Spacing in conversations and Markdown content across Buzz", + ); + await expect(fontSizeDescription).toHaveText( + "Applies across conversations and interface text", + ); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0.125, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.5, + rowPadding: 0.25, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect + .poll(() => + Promise.all([ + densityControl.evaluate( + (element) => element.getBoundingClientRect().width, + ), + fontSizeControl.evaluate( + (element) => element.getBoundingClientRect().width, + ), + page + .getByTestId("appearance-color-mode-control") + .evaluate((element) => element.getBoundingClientRect().width), + ]), + ) + .toEqual([288, 288, 240]); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["14px", "20px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); + await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]); + await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s"); + await expect(densityIndicator).toHaveCSS("transition-property", /transform/); + await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s"); + await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/); + await expect + .poll(async () => { + const [previewBackground, labelBackground, controlBackground] = + await Promise.all([ + previewSurface.evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ), + previewChip.evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ), + densityControl.evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ), + ]); + return { + labelIsAnnotation: labelBackground !== controlBackground, + previewBackground, + }; + }) + .toEqual({ + labelIsAnnotation: true, + previewBackground: "rgba(0, 0, 0, 0)", + }); + const previewSurfaceBox = await previewSurface.boundingBox(); + const previewChipBox = await previewChip.boundingBox(); + const firstPreviewMessageBox = await firstPreviewMessage.boundingBox(); + expect(previewSurfaceBox).not.toBeNull(); + expect(previewChipBox).not.toBeNull(); + expect(firstPreviewMessageBox).not.toBeNull(); + if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) { + throw new Error("Conversation preview geometry is missing"); + } + const previewChipRightInset = + previewSurfaceBox.x + + previewSurfaceBox.width - + (previewChipBox.x + previewChipBox.width); + expect(previewChipRightInset).toBeGreaterThanOrEqual(13); + expect(previewChipRightInset).toBeLessThanOrEqual(15); + const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y; + expect(previewChipTopInset).toBeGreaterThanOrEqual(13); + expect(previewChipTopInset).toBeLessThanOrEqual(15); + await expect(previewContent).toHaveCSS("padding-top", "16px"); + await expect(previewContent).toHaveCSS("padding-right", "16px"); + await expect(previewContent).toHaveCSS("padding-bottom", "16px"); + await expect(previewContent).toHaveCSS("padding-left", "16px"); + expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual( + 15, + ); + expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual( + 17, + ); + expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual( + 15, + ); + expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual( + 17, + ); + await densityIndicator.evaluate((element) => { + element.addEventListener( + "transitionrun", + () => element.setAttribute("data-transition-ran", "true"), + { once: true }, + ); + }); + + await compact.click(); + await expect(densityIndicator).toHaveAttribute("data-transition-ran", "true"); + await expect(root).toHaveAttribute("data-conversation-density", "compact"); + await expect(root).toHaveAttribute("data-font-size", "default"); + await expect(compact).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + CONVERSATION_DENSITY_STORAGE_KEY, + ), + ) + .toBe("compact"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.375, + rowPadding: 0.25, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); + await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]); + + await larger.click(); + await expect(root).toHaveAttribute("data-conversation-density", "compact"); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(larger).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("larger"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 17.142857, + bodyGap: 0, + fontSize: "calc(17.142857px * .875)", + lineHeight: "calc(17.142857px * 1.25)", + paragraphGap: 0.375, + rowPadding: 0.25, + timestampFontSize: "calc(17.142857px * .75)", + timestampLineHeight: 17.142857, + }); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["15px", "21.4286px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "15px", + lineHeight: "21.4286px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["12.8571px", "17.1429px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "15px"]); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/15-conversation-compact-larger.png`, + }); + + await spacious.click(); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(spacious).toHaveAttribute("aria-pressed", "true"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 17.142857, + bodyGap: 0.25, + fontSize: "calc(17.142857px * .875)", + lineHeight: "calc(17.142857px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(17.142857px * .75)", + timestampLineHeight: 17.142857, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "15px", + lineHeight: "21.4286px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["12.8571px", "17.1429px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "15px"]); + + await smaller.click(); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "smaller"); + await expect(smaller).toHaveAttribute("aria-pressed", "true"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 14.857143, + bodyGap: 0.25, + fontSize: "calc(14.857143px * .875)", + lineHeight: "calc(14.857143px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(14.857143px * .75)", + timestampLineHeight: 14.857143, + }); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["13px", "18.5714px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "13px", + lineHeight: "18.5714px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["11.1429px", "14.8571px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["22.2857px", "13px", "13px"]); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/16-conversation-spacious-smaller.png`, + }); + + await comfortable.click(); + await defaultSize.click(); + await expect(root).toHaveAttribute( + "data-conversation-density", + "comfortable", + ); + await expect(comfortable).toHaveAttribute("aria-pressed", "true"); + + const controlBox = await densityControl.boundingBox(); + const compactBox = await compact.boundingBox(); + const spaciousBox = await spacious.boundingBox(); + expect(controlBox).not.toBeNull(); + expect(compactBox).not.toBeNull(); + expect(spaciousBox).not.toBeNull(); + if (!controlBox || !compactBox || !spaciousBox) { + throw new Error("Conversation density control geometry is missing"); + } + await page.mouse.move( + compactBox.x + compactBox.width / 2, + controlBox.y + controlBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + spaciousBox.x + spaciousBox.width / 2, + controlBox.y + controlBox.height / 2, + ); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "default"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + CONVERSATION_DENSITY_STORAGE_KEY, + ), + ) + .toBe("comfortable"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0.25, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await page.mouse.up(); + await expect(spacious).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + CONVERSATION_DENSITY_STORAGE_KEY, + ), + ) + .toBe("spacious"); + await comfortable.click(); + + const fontSizeControlBox = await fontSizeControl.boundingBox(); + const smallerBox = await smaller.boundingBox(); + const largerBox = await larger.boundingBox(); + expect(fontSizeControlBox).not.toBeNull(); + expect(smallerBox).not.toBeNull(); + expect(largerBox).not.toBeNull(); + if (!fontSizeControlBox || !smallerBox || !largerBox) { + throw new Error("Font size control geometry is missing"); + } + await page.mouse.move( + smallerBox.x + smallerBox.width / 2, + fontSizeControlBox.y + fontSizeControlBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + largerBox.x + largerBox.width / 2, + fontSizeControlBox.y + fontSizeControlBox.height / 2, + ); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(root).toHaveAttribute( + "data-conversation-density", + "comfortable", + ); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("default"); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["15px", "21.4286px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "15px"]); + + // Losing the window during a scrub cancels the temporary preview rather + // than leaving presentation and persisted selection out of sync. + await page.evaluate(() => window.dispatchEvent(new Event("blur"))); + await expect(root).toHaveAttribute("data-font-size", "default"); + await expect(defaultSize).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("default"); + + // Cancellation resets the gesture completely; the next selection persists. + await larger.click(); + await expect(larger).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("larger"); + await defaultSize.click(); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/14-conversation-preferences.png`, + }); +}); + test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => { await seedTheme(page, "buzz"); await installMockBridge(page); @@ -1051,6 +1559,10 @@ test("glass background keeps the content panel solid", async ({ page }) => { const matchingRadiusControls = [ page.getByTestId("appearance-color-mode-control"), page.getByTestId("appearance-color-mode-indicator"), + page.getByTestId("font-size-control"), + page.getByTestId("font-size-control-indicator"), + page.getByTestId("conversation-density-control"), + page.getByTestId("conversation-density-control-indicator"), page.getByTestId("theme-style-trigger"), page.getByTestId("link-preview-style-trigger"), page.getByTestId("thread-layout-trigger"), diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 3f6d38602a..57b35db386 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge"; import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; const RELAY_URL = "ws://localhost:3000"; +const THEME_STORAGE_KEY = "buzz-theme"; const OWNER_PUBKEY = "deadbeef".repeat(8); function snapshotKey(relayUrl: string) { @@ -23,6 +24,25 @@ const COMMUNITY_B = { addedAt: "2026-01-02T00:00:00.000Z", }; +async function expectContentSurfaceHorizontalGutters( + page: import("@playwright/test").Page, + expectedLeftGutter = 1, +) { + const [mainInsetBox, contentBox] = await Promise.all([ + page.locator("[data-buzz-glass-inset]").boundingBox(), + page.locator("[data-buzz-content-surface]").first().boundingBox(), + ]); + expect(mainInsetBox).not.toBeNull(); + expect(contentBox).not.toBeNull(); + const leftGutter = (contentBox?.x ?? 0) - (mainInsetBox?.x ?? 0); + const rightGutter = + (mainInsetBox?.x ?? 0) + + (mainInsetBox?.width ?? 0) - + ((contentBox?.x ?? 0) + (contentBox?.width ?? 0)); + expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5); + expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5); +} + async function seedCommunities( page: import("@playwright/test").Page, communities: Array>, @@ -64,7 +84,7 @@ test.describe("community rail", () => { "overflow", "visible", ); - await expect(rail).toHaveCSS("z-index", "0"); + await expect(rail).toHaveCSS("z-index", "20"); const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); @@ -137,6 +157,7 @@ test.describe("community rail", () => { // The add-community affordance lives at the bottom of the rail. await expect(page.getByTestId("community-rail-add")).toBeVisible(); + await expectContentSurfaceHorizontalGutters(page); }); test("restores pointer events after dismissing community settings", async ({ @@ -275,10 +296,87 @@ test.describe("community rail", () => { expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0); expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0); - await page.getByTestId("community-switcher").click(); - const menu = page.getByRole("menu", { name: "Community actions" }); + await communityTrigger.evaluate((trigger) => { + trigger.addEventListener( + "mouseenter", + () => { + trigger.dataset.hoverStartedAt = String(performance.now()); + }, + { once: true }, + ); + trigger.addEventListener("mouseleave", () => { + trigger.dataset.leftAt = String(performance.now()); + }); + const observer = new MutationObserver((records) => { + if ( + trigger.getAttribute("aria-expanded") === "true" && + !trigger.dataset.expandedAt + ) { + trigger.dataset.expandedAt = String(performance.now()); + } + if ( + records.some( + (record) => + record.attributeName === "aria-expanded" && + record.oldValue === "true", + ) + ) { + trigger.dataset.closedAfterOpening = "true"; + } + }); + observer.observe(trigger, { + attributeFilter: ["aria-expanded"], + attributeOldValue: true, + attributes: true, + }); + }); + await communityTrigger.hover(); + await expect(menu).toBeVisible({ timeout: 700 }); + const openDelayMs = await communityTrigger.evaluate((trigger) => { + const hoverStartedAt = Number(trigger.dataset.hoverStartedAt); + const expandedAt = Number(trigger.dataset.expandedAt); + if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) { + throw new Error("Community actions open timing was not recorded"); + } + return expandedAt - hoverStartedAt; + }); + expect(openDelayMs).toBeGreaterThanOrEqual(40); + expect(openDelayMs).toBeLessThan(300); + + const openTriggerBox = await communityTrigger.boundingBox(); + const menuBox = await menu.boundingBox(); + expect(openTriggerBox).not.toBeNull(); + expect(menuBox).not.toBeNull(); + if (!openTriggerBox || !menuBox) { + throw new Error("Community actions geometry unavailable"); + } + const triggerExitX = openTriggerBox.x + openTriggerBox.width - 1; + const triggerExitY = Math.min( + openTriggerBox.y + openTriggerBox.height - 4, + menuBox.y + menuBox.height - 4, + ); + await page.mouse.move(triggerExitX, triggerExitY); + await page.mouse.move(menuBox.x + 8, menuBox.y - 8); + await page.waitForTimeout(80); + await page.mouse.move(menuBox.x + 8, menuBox.y + 8); + const bridgeDurationMs = await communityTrigger.evaluate((trigger) => { + const leftAt = Number(trigger.dataset.leftAt); + if (!Number.isFinite(leftAt)) { + throw new Error( + "Community actions trigger exit timing was not recorded", + ); + } + return performance.now() - leftAt; + }); + expect(bridgeDurationMs).toBeGreaterThanOrEqual(60); + expect(bridgeDurationMs).toBeLessThan(140); + await page.waitForTimeout(180); await expect(menu).toBeVisible(); + await expect(communityTrigger).not.toHaveAttribute( + "data-closed-after-opening", + "true", + ); await expect( menu.getByRole("menuitem", { name: "Copy community URL" }), ).toBeVisible(); @@ -1133,7 +1231,36 @@ test.describe("community rail", () => { ).toBeVisible(); }); + test("keeps the gutter when the mobile sidebar closes without a rail", async ({ + page, + }) => { + await page.setViewportSize({ width: 740, height: 516 }); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await expect( + page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), + ).toBeHidden(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "width", + "8px", + ); + await expectContentSurfaceHorizontalGutters(page, 9); + }); + test("hides the rail with a single community", async ({ page }) => { + await page.addInitScript((themeStorageKey) => { + window.localStorage.setItem(themeStorageKey, "buzz-dark"); + }, THEME_STORAGE_KEY); await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); await page.goto("/"); @@ -1142,6 +1269,25 @@ test.describe("community rail", () => { // adds nothing). await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect(page.getByTestId("community-rail")).toHaveCount(0); + + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.locator('[data-side="left"][data-state="collapsed"]'), + ).toBeVisible(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "width", + "8px", + ); + const sidebarBackground = await page + .locator("[data-buzz-glass-inset]") + .evaluate((element) => getComputedStyle(element).backgroundColor); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "background-color", + sidebarBackground, + ); + await expectContentSurfaceHorizontalGutters(page, 9); }); test("keeps the rail visible when the sidebar is collapsed", async ({ @@ -1174,6 +1320,10 @@ test.describe("community rail", () => { page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), ).toBeVisible(); await expect(page.getByTestId("community-rail-add")).toBeVisible(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCount( + 0, + ); + await expectContentSurfaceHorizontalGutters(page); }); test("clears the macOS traffic lights", async ({ page }) => { diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts index 9b0bb051fc..d6cd05eb0f 100644 --- a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts +++ b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts @@ -12,8 +12,8 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); -/** Hover the trigger, then slide the cursor onto the tooltip popup and - * assert the tooltip dismisses instead of persisting. */ +/** Hover the trigger through the shared dwell, then slide the cursor onto the + * tooltip popup and assert the tooltip dismisses instead of persisting. */ async function expectTooltipDismissesOnLeave( page: import("@playwright/test").Page, trigger: import("@playwright/test").Locator, @@ -22,7 +22,9 @@ async function expectTooltipDismissesOnLeave( await trigger.hover(); const tip = page.getByRole("tooltip", { name: tooltipName }); - await expect(tip).toBeVisible(); + await page.waitForTimeout(400); + await expect(tip).toHaveCount(0); + await expect(tip).toBeVisible({ timeout: 1_000 }); // Slide off the trigger onto the tooltip popup. const box = await tip.boundingBox(); @@ -48,6 +50,26 @@ test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ); }); +test("adjacent composer tooltips each require a fresh dwell", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-insert-mention").hover(); + await page.waitForTimeout(400); + const mentionTooltip = page.getByRole("tooltip", { name: "Mention someone" }); + await expect(mentionTooltip).toHaveCount(0); + await expect(mentionTooltip).toBeVisible({ timeout: 1_000 }); + + await page.getByRole("button", { name: "Attach file" }).hover(); + await page.waitForTimeout(400); + const attachTooltip = page.getByRole("tooltip", { name: "Attach file" }); + await expect(attachTooltip).toHaveCount(0); + await expect(attachTooltip).toBeVisible({ timeout: 1_000 }); +}); + test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({ page, }) => { @@ -60,6 +82,9 @@ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", const bold = page.getByRole("button", { name: "Bold" }); await expect(bold).toBeVisible(); + // The formatting strip animates into place; wait for its delayed entrance to + // settle so the pointer remains over the trigger for the full dwell. + await page.waitForTimeout(300); // Tooltip text is "