Skip to content
710 changes: 273 additions & 437 deletions crates/buzz-core/src/private_managed_agent.rs

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,27 @@ pub struct AppState {
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
/// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex<Option<String>>,
/// Set during backend setup when managed agents are eligible for launch
/// restore. `apply_workspace` consumes it after installing the workspace
/// relay and identity, so agents never start against the fallback relay.
/// Set during backend setup when managed agents are eligible for launch restore.
/// `apply_workspace` consumes it after installing the workspace relay and
/// identity, so agents never start against the fallback relay.
pub managed_agent_restore_pending: AtomicBool,
/// Whether desktop may repair managed-agent kind:0 profiles from its local
/// records. Disabled by the agent-managed profiles experiment so an agent's
/// own profile updates are not overwritten on start or restore.
/// Whether desktop may repair managed-agent kind:0 profiles from local records.
/// Disabled by the experiment so agent profile updates survive start/restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
/// Shared shutdown signal checked by launch-time agent restoration.
/// Shared shutdown signal for launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes every managed-runtime transition that changes the protected
/// PID set: spawn/register, adoption, stop, shutdown, and sweep snapshots.
/// Never perform network I/O while holding this lock.
pub managed_agent_runtime_transition: Mutex<()>,
pub managed_agents_store_lock: Mutex<()>,
pub(crate) private_managed_agent_overlay:
Mutex<crate::managed_agents::private_config_overlay::PrivateConfigOverlay>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
pub huddle_state: Mutex<HuddleState>,
pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
/// Tauri app handle — stored after setup so huddle commands can emit
/// `huddle-state-changed` events without needing the handle threaded
/// through every call site.
/// Tauri handle for emitting huddle events.
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex<Option<AppHandle>>,
Expand Down Expand Up @@ -213,6 +212,7 @@ pub fn build_app_state() -> AppState {
managed_agent_runtime_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
managed_agents_store_lock: Mutex::new(()),
private_managed_agent_overlay: Mutex::new(Default::default()),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
session_config_cache: Mutex::new(HashMap::new()),
Expand Down
95 changes: 14 additions & 81 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,17 @@ pub async fn update_managed_agent(
}

let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
// Item 2: fold the relay-config overlay onto the disk record BEFORE
// applying the user's patch, so the edit is authored on top of the
// config this device is actually following. Without this, retaining
// the raw disk record republishes every OTHER field from stale disk
// and LWW makes that the new relay head. Ordering is load-bearing:
// resolving AFTER the patch would discard the user's edit instead.
if let Ok(resolved) =
crate::managed_agents::private_config_overlay::resolved_local_record(&state, record)
{
*record = resolved;
}
let previous_record = record.clone();

let mut name_changed = false;
Expand Down Expand Up @@ -937,87 +948,9 @@ pub async fn update_managed_agent(
})
}

// ── Model normalization ───────────────────────────────────────────────────────

/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend.
///
/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState),
/// deduplicates by ID (stable takes precedence), and returns a unified list.
pub(super) fn normalize_agent_models(
raw: &serde_json::Value,
persisted_model: Option<String>,
) -> AgentModelsResponse {
let agent_name = raw["agent"]["name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let agent_version = raw["agent"]["version"]
.as_str()
.unwrap_or("unknown")
.to_string();

let mut models: Vec<AgentModelInfo> = Vec::new();
let mut seen_ids: HashSet<String> = HashSet::new();

// 1. Stable configOptions (preferred). Only entries with category "model"
// are model options — the CLI pre-filters, but we're defensive here.
if let Some(config_options) = raw["stable"]["configOptions"].as_array() {
for opt in config_options {
if opt.get("category").and_then(|c| c.as_str()) != Some("model") {
continue;
}
if let Some(options) = opt.get("options").and_then(|v| v.as_array()) {
for o in options {
if let Some(value) = o.get("value").and_then(|v| v.as_str()) {
if seen_ids.insert(value.to_string()) {
models.push(AgentModelInfo {
id: value.to_string(),
name: o
.get("displayName")
.and_then(|v| v.as_str())
.map(str::to_string),
description: None,
});
}
}
}
}
}
}

// 2. Unstable availableModels (fallback — skip duplicates from stable).
let mut agent_default_model: Option<String> = None;
if let Some(unstable) = raw.get("unstable") {
agent_default_model = unstable["currentModelId"].as_str().map(str::to_string);
if let Some(available) = unstable["availableModels"].as_array() {
for m in available {
if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) {
if seen_ids.insert(id.to_string()) {
models.push(AgentModelInfo {
id: id.to_string(),
name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
description: m
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string),
});
}
}
}
}
}

let supports_switching = !models.is_empty();

AgentModelsResponse {
agent_name,
agent_version,
models,
agent_default_model,
selected_model: persisted_model,
supports_switching,
}
}
#[path = "agent_models_normalize.rs"]
mod normalize;
pub(super) use normalize::normalize_agent_models;

#[cfg(test)]
#[path = "agent_models_tests.rs"]
Expand Down
89 changes: 89 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_normalize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Normalization of raw `buzz-acp models --json` output into the frontend DTO.
//!
//! Split out of `agent_models.rs` to keep that file inside the desktop
//! file-size ratchet; it is a pure transform with no shared state, so the
//! seam is the same one the discovery/provider helpers already use.

use std::collections::HashSet;

use crate::managed_agents::{AgentModelInfo, AgentModelsResponse};

/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend.
///
/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState),
/// deduplicates by ID (stable takes precedence), and returns a unified list.
pub(crate) fn normalize_agent_models(
raw: &serde_json::Value,
persisted_model: Option<String>,
) -> AgentModelsResponse {
let agent_name = raw["agent"]["name"]
.as_str()
.unwrap_or("unknown")
.to_string();
let agent_version = raw["agent"]["version"]
.as_str()
.unwrap_or("unknown")
.to_string();

let mut models: Vec<AgentModelInfo> = Vec::new();
let mut seen_ids: HashSet<String> = HashSet::new();

// 1. Stable configOptions (preferred). Only entries with category "model"
// are model options — the CLI pre-filters, but we're defensive here.
if let Some(config_options) = raw["stable"]["configOptions"].as_array() {
for opt in config_options {
if opt.get("category").and_then(|c| c.as_str()) != Some("model") {
continue;
}
if let Some(options) = opt.get("options").and_then(|v| v.as_array()) {
for o in options {
if let Some(value) = o.get("value").and_then(|v| v.as_str()) {
if seen_ids.insert(value.to_string()) {
models.push(AgentModelInfo {
id: value.to_string(),
name: o
.get("displayName")
.and_then(|v| v.as_str())
.map(str::to_string),
description: None,
});
}
}
}
}
}
}

// 2. Unstable availableModels (fallback — skip duplicates from stable).
let mut agent_default_model: Option<String> = None;
if let Some(unstable) = raw.get("unstable") {
agent_default_model = unstable["currentModelId"].as_str().map(str::to_string);
if let Some(available) = unstable["availableModels"].as_array() {
for m in available {
if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) {
if seen_ids.insert(id.to_string()) {
models.push(AgentModelInfo {
id: id.to_string(),
name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
description: m
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string),
});
}
}
}
}
}

let supports_switching = !models.is_empty();

AgentModelsResponse {
agent_name,
agent_version,
models,
agent_default_model,
selected_model: persisted_model,
supports_switching,
}
}
Loading
Loading