Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6971822
fix(agents): sync private managed config via relay
wesbillman Aug 6, 2026
a9b648b
fix(agents): preserve managed overlay lifecycle
wesbillman Aug 6, 2026
c80c4c1
fix(agents): persist relay config across restarts and stop stale repu…
Aug 6, 2026
aa39d72
fix(agents): stop boot reconcile republishing stale config over a new…
Aug 6, 2026
6f486e8
fix(agents): write self-authored config back to the private-config ov…
Aug 6, 2026
d945edf
Merge origin/main into carl/relay-primary-agent-config
Aug 7, 2026
692fdaf
fix(desktop): export/card resolves through the 30179 overlay; snapsho…
Aug 7, 2026
2719088
test(desktop): guard that every export resolver folds the 30179 overlay
Aug 7, 2026
637a796
refactor(desktop): satisfy the file-size ratchet — extract the overla…
Aug 7, 2026
11eee16
Merge origin/main into carl/relay-primary-agent-config
Aug 9, 2026
67f4895
Merge origin/main into carl/relay-primary-agent-config
Aug 11, 2026
01eec9b
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
Aug 13, 2026
2b02892
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
wesbillman Aug 16, 2026
a6d4b30
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
Aug 20, 2026
6c7bb84
fix(desktop): harden private-config tombstones and equal-second reten…
Aug 20, 2026
e4db7b3
chore(desktop): satisfy file-size ratchet for review-fix growth
Aug 20, 2026
9ed90c1
fix(desktop): launch restore spawns relay-resolved config, not raw di…
Aug 20, 2026
ab620e9
Merge remote-tracking branch 'origin/main' into carl/relay-primary-ag…
Aug 20, 2026
bd856e9
Keep boot reconcile unit tests off the live OS keyring
Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
710 changes: 273 additions & 437 deletions crates/buzz-core/src/private_managed_agent.rs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,21 @@ pub struct AppState {
pub managed_agent_restore_pending: AtomicBool,
/// Disabled by agent-managed profiles 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 provider_deploy_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
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>>,
/// Port of the localhost media streaming proxy (set during setup).
Expand Down Expand Up @@ -211,6 +210,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()),
provider_deploy_locks: Mutex::new(HashMap::new()),
Expand Down
84 changes: 3 additions & 81 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,87 +702,9 @@ mod update;
pub use update::update_managed_agent;
pub(super) use update::{flush_managed_agent_policy, managed_agent_access_policy_changed};

// ── 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,
}
}
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,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
Loading
Loading