Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1752,7 +1752,7 @@ pub fn spawn_agent_child(
if let Some(toolsets) = &record.mcp_toolsets {
command.env("BUZZ_TOOLSETS", toolsets);
} else {
command.env("BUZZ_TOOLSETS", "default,canvas,forums,dms,media");
command.env("BUZZ_TOOLSETS", super::types::DEFAULT_MCP_TOOLSETS);
}
command.env_remove("BUZZ_ACP_PRIVATE_KEY");
command.env_remove("BUZZ_ACP_API_TOKEN");
Expand Down
26 changes: 23 additions & 3 deletions desktop/src-tauri/src/managed_agents/spawn_hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,31 @@ pub(crate) fn spawn_config_hash(
record.provider.hash(&mut hasher);
record.auth_tag.hash(&mut hasher);
record.respond_to.as_str().hash(&mut hasher);
record.respond_to_allowlist.hash(&mut hasher);
// The allowlist is hashed as the env receives it: spawn sets
// BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized
// (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits
// that don't survive normalization, or edits while another mode is
// active, must not badge. A list spawn would reject hashes raw: the
// stamped hash comes from a successful spawn, so any invalid edit
// correctly compares unequal.
if record.respond_to == super::types::RespondTo::Allowlist {
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
.hash(&mut hasher);
}
record.idle_timeout_seconds.hash(&mut hasher);
record.max_turn_duration_seconds.hash(&mut hasher);
// Spawn writes BUZZ_ACP_MAX_TURN_DURATION and BUZZ_TOOLSETS with defaults
// filled in, so None and an explicit default are the same spawned value.
record
.max_turn_duration_seconds
.unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS)
.hash(&mut hasher);
record.parallelism.hash(&mut hasher);
record.mcp_toolsets.hash(&mut hasher);
record
.mcp_toolsets
.as_deref()
.unwrap_or(super::types::DEFAULT_MCP_TOOLSETS)
.hash(&mut hasher);
record.persona_team_dir.hash(&mut hasher);
record.persona_name_in_team.hash(&mut hasher);

Expand Down
95 changes: 95 additions & 0 deletions desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use crate::managed_agents::types::RespondTo;
use std::collections::BTreeMap;

fn record() -> ManagedAgentRecord {
Expand Down Expand Up @@ -183,13 +184,107 @@ fn workspace_relay_change_ignored_for_pinned_record_relay() {
fn respond_to_allowlist_edit_changes_hash() {
let rec = record();
let mut edited = record();
edited.respond_to = RespondTo::Allowlist;
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn allowlist_ignored_when_mode_is_not_allowlist() {
// Spawn only sets BUZZ_ACP_RESPOND_TO_ALLOWLIST in allowlist mode, so
// editing the (dormant) list under owner-only must not badge.
let rec = record();
let mut edited = record();
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn allowlist_normalization_equivalent_edits_do_not_change_hash() {
// The env receives the normalized list (trim/lowercase/dedup), so edits
// that normalize to the same value must not badge.
let mut rec = record();
rec.respond_to = RespondTo::Allowlist;
rec.respond_to_allowlist = vec!["a".repeat(64)];
let mut edited = rec.clone();
edited.respond_to_allowlist = vec![
format!(" {} ", "A".repeat(64)), // whitespace + case
"a".repeat(64), // duplicate
];
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn allowlist_content_edit_still_changes_hash() {
let mut rec = record();
rec.respond_to = RespondTo::Allowlist;
rec.respond_to_allowlist = vec!["a".repeat(64)];
let mut edited = rec.clone();
edited.respond_to_allowlist = vec!["b".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn explicit_default_max_turn_duration_does_not_change_hash() {
// Spawn writes BUZZ_ACP_MAX_TURN_DURATION with the default filled in, so
// None → Some(default) is the same spawned value and must not badge.
let rec = record();
let mut edited = record();
edited.max_turn_duration_seconds =
Some(crate::managed_agents::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS);
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn non_default_max_turn_duration_changes_hash() {
let rec = record();
let mut edited = record();
edited.max_turn_duration_seconds = Some(42);
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn explicit_default_toolsets_do_not_change_hash() {
// Spawn falls BUZZ_TOOLSETS back to the default set, so None → an
// explicit copy of the default is the same spawned value.
let rec = record();
let mut edited = record();
edited.mcp_toolsets = Some(crate::managed_agents::types::DEFAULT_MCP_TOOLSETS.to_string());
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn non_default_toolsets_change_hash() {
let rec = record();
let mut edited = record();
edited.mcp_toolsets = Some("default,canvas".to_string());
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}

#[test]
fn non_spawn_bookkeeping_fields_do_not_change_hash() {
// updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,9 @@ pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320;
/// 1 hour — absolute wall-clock safety cap per turn.
pub const DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS: u64 = 3600;
pub const DEFAULT_AGENT_PARALLELISM: u32 = 24;
/// Toolsets injected as `BUZZ_TOOLSETS` when the record doesn't pin its own —
/// single source of truth for the spawn env and the spawn-config hash.
pub const DEFAULT_MCP_TOOLSETS: &str = "default,canvas,forums,dms,media";

fn default_agent_parallelism() -> u32 {
DEFAULT_AGENT_PARALLELISM
Expand Down