diff --git a/Justfile b/Justfile index 4ec1af2f44..301cd42cf7 100644 --- a/Justfile +++ b/Justfile @@ -245,6 +245,9 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ cargo test compiled_policy_matches_expected -- --ignored --nocapture + env -u VITE_BUZZ_BESTIE \ + BUZZ_TEST_EXPECTED_BESTIE=false \ + cargo test compiled_bestie_flag_matches_expected -- --ignored --nocapture echo "=== Internal build (flags set) → expect true ===" BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ @@ -255,6 +258,9 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ cargo test compiled_policy_matches_expected -- --ignored --nocapture + VITE_BUZZ_BESTIE=1 \ + BUZZ_TEST_EXPECTED_BESTIE=true \ + cargo test compiled_bestie_flag_matches_expected -- --ignored --nocapture echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 6292a4dd25..f2dbd59635 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -9,6 +9,7 @@ use crate::managed_agents::{ AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, }, + storage::{filter_agent_definitions_for_build, filter_managed_agents_for_build}, BackendKind, ManagedAgentRecord, RespondTo, }; use std::collections::BTreeMap; @@ -180,6 +181,25 @@ fn resolve_unknown_id_returns_error() { assert!(result.unwrap_err().contains("ghost")); } +#[test] +fn capability_off_shared_snapshot_and_card_lookup_rejects_known_bestie_ids() { + let mut instances = vec![ + make_instance("bestie-pubkey", "builtin:bestie"), + make_instance("fizz-pubkey", "builtin:fizz"), + ]; + let mut definitions = vec![ + make_definition("builtin:bestie"), + make_definition("builtin:fizz"), + ]; + filter_managed_agents_for_build(&mut instances, false); + filter_agent_definitions_for_build(&mut definitions, false); + + assert!(resolve_from_lists("bestie-pubkey", &instances, &definitions).is_err()); + assert!(resolve_from_lists("builtin:bestie", &instances, &definitions).is_err()); + assert!(resolve_from_lists("fizz-pubkey", &instances, &definitions).is_ok()); + assert!(resolve_from_lists("builtin:fizz", &instances, &definitions).is_ok()); +} + // ── Validator fail-closed cases ─────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 584829f6b4..c6a95b7355 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -94,7 +94,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ }, ]; -fn bestie_build_enabled() -> bool { +pub(crate) fn bestie_build_enabled() -> bool { option_env!("VITE_BUZZ_BESTIE") == Some("1") } @@ -195,7 +195,7 @@ fn built_in_order(id: &str) -> Option { .position(|persona| persona.id == id) } -fn persona_available_in_build(id: &str, include_bestie: bool) -> bool { +pub(crate) fn persona_available_in_build(id: &str, include_bestie: bool) -> bool { include_bestie || id != BESTIE_PERSONA_ID } diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 80b63e6d36..c3fed1e5c2 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,6 +1,6 @@ use super::{ - built_in_persona_records, built_in_persona_records_for_build, definitions_for_save, - ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, + bestie_build_enabled, built_in_persona_records, built_in_persona_records_for_build, + definitions_for_save, ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, merge_personas_for_build, migrate_retired_personas, validate_persona_activation_change, validate_persona_deletion, visible_personas_for_build, BUILT_IN_PERSONAS, RETIRED_PERSONAS, }; @@ -35,7 +35,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { #[test] fn merge_personas_adds_missing_built_ins() { - let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z"); + let (records, changed) = merge_personas_for_build(Vec::new(), "2026-03-19T00:00:00Z", false); assert!(changed); assert_eq!(records.len(), BUILT_IN_PERSONAS.len() - 1); @@ -76,6 +76,16 @@ fn bestie_persona_requires_the_internal_build_capability() { assert!(bestie.is_active); } +#[test] +#[ignore = "run explicitly in both compiled feature states"] +fn compiled_bestie_flag_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_BESTIE") + .expect("BUZZ_TEST_EXPECTED_BESTIE must be set") + .parse::() + .expect("BUZZ_TEST_EXPECTED_BESTIE must be true or false"); + assert_eq!(bestie_build_enabled(), expected); +} + #[test] fn ineligible_build_hides_bestie_without_demoting_it() { let now = "2026-03-19T00:00:00Z"; diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 88c84f9346..099eaec36e 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -271,16 +271,57 @@ pub fn load_managed_agents( app: &AppHandle, ) -> Result, String> { let mut records = load_agent_store(app)?; - records.retain(|record| !record.pubkey.is_empty()); + filter_managed_agents_for_build( + &mut records, + crate::managed_agents::personas::bestie_build_enabled(), + ); hydrate_keys(&mut records); Ok(records) } +pub(crate) fn filter_managed_agents_for_build( + records: &mut Vec, + include_bestie: bool, +) { + records.retain(|record| { + !record.pubkey.is_empty() + && record.persona_id.as_deref().is_none_or(|persona_id| { + crate::managed_agents::personas::persona_available_in_build( + persona_id, + include_bestie, + ) + }) + }); +} + /// Load the key-less agent *definitions* (former personas) from the unified /// store. The persona compatibility shim (`load_personas`) presents these in /// the legacy shape via `to_definition_view`. pub(crate) fn load_agent_definitions( app: &AppHandle, +) -> Result, String> { + let mut records = load_agent_definitions_unfiltered(app)?; + filter_agent_definitions_for_build( + &mut records, + crate::managed_agents::personas::bestie_build_enabled(), + ); + Ok(records) +} + +pub(crate) fn filter_agent_definitions_for_build( + records: &mut Vec, + include_bestie: bool, +) { + records.retain(|record| { + record.pubkey.is_empty() + && record.slug.as_deref().is_none_or(|slug| { + crate::managed_agents::personas::persona_available_in_build(slug, include_bestie) + }) + }); +} + +pub(crate) fn load_agent_definitions_unfiltered( + app: &AppHandle, ) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| record.pubkey.is_empty()); @@ -376,8 +417,14 @@ pub fn save_managed_agents( app: &AppHandle, records: &[ManagedAgentRecord], ) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); - let mut sorted = records.to_vec(); + let existing = load_agent_store(app).unwrap_or_default(); + let definitions = existing + .iter() + .filter(|record| record.pubkey.is_empty()) + .cloned() + .collect(); + let include_bestie = crate::managed_agents::personas::bestie_build_enabled(); + let mut sorted = instances_for_save(records, &existing, include_bestie); // A caller-supplied key-less record would collide with the definition // half re-read below; instances always carry a pubkey. sorted.retain(|record| !record.pubkey.is_empty()); @@ -396,6 +443,48 @@ pub fn save_managed_agents( write_agent_store(app, definitions, sorted) } +fn instances_for_save( + records: &[ManagedAgentRecord], + existing: &[ManagedAgentRecord], + include_bestie: bool, +) -> Vec { + let mut complete: Vec<_> = records + .iter() + .filter(|record| { + !record.pubkey.is_empty() + && record.persona_id.as_deref().is_none_or(|persona_id| { + crate::managed_agents::personas::persona_available_in_build( + persona_id, + include_bestie, + ) + }) + }) + .cloned() + .collect(); + + if !include_bestie { + let hidden: Vec<_> = existing + .iter() + .filter(|record| { + !record.pubkey.is_empty() + && record.persona_id.as_deref().is_some_and(|persona_id| { + !crate::managed_agents::personas::persona_available_in_build( + persona_id, + include_bestie, + ) + }) + && !complete + .iter() + .any(|candidate| candidate.pubkey == record.pubkey) + }) + .cloned() + .collect(); + complete.extend(hidden); + } + + complete +} + /// Save the key-less agent *definitions*, preserving the keyed instances — /// the definition-side mirror of [`save_managed_agents`]. pub(crate) fn save_agent_definitions( diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac..b8d26ee697 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -12,7 +12,8 @@ use std::path::Path; use tempfile::NamedTempFile; use super::{ - agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, + agent_keyring_name, filter_agent_definitions_for_build, filter_managed_agents_for_build, + hydrate_keys_with, instances_for_save, migrate_inline_key, persist_agent_keys_with, KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, }; @@ -143,6 +144,59 @@ fn record_with_pubkey_and_key(pubkey: &str, nsec: &str) -> ManagedAgentRecord { .expect("sample record") } +fn linked_record(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { + let mut record = record_with_pubkey_and_key(pubkey, ""); + record.persona_id = Some(persona_id.to_string()); + record +} + +fn definition_record(slug: &str) -> ManagedAgentRecord { + let mut record = record_with_pubkey_and_key("", ""); + record.slug = Some(slug.to_string()); + record +} + +#[test] +fn capability_off_filters_bestie_instances_and_definitions() { + let bestie = linked_record("bestie-pubkey", "builtin:bestie"); + let fizz = linked_record("fizz-pubkey", "builtin:fizz"); + let custom = linked_record("custom-pubkey", "custom:helper"); + let mut instances = vec![bestie.clone(), fizz.clone(), custom.clone()]; + + filter_managed_agents_for_build(&mut instances, false); + + assert_eq!(instances, vec![fizz, custom]); + + let bestie_definition = definition_record("builtin:bestie"); + let fizz_definition = definition_record("builtin:fizz"); + let mut definitions = vec![bestie_definition, fizz_definition.clone()]; + filter_agent_definitions_for_build(&mut definitions, false); + assert_eq!(definitions, vec![fizz_definition]); + + let mut eligible_instances = vec![bestie]; + filter_managed_agents_for_build(&mut eligible_instances, true); + assert_eq!(eligible_instances.len(), 1); +} + +#[test] +fn capability_off_save_preserves_hidden_bestie_instance_unchanged() { + let original_bestie = linked_record("bestie-pubkey", "builtin:bestie"); + let fizz = linked_record("fizz-pubkey", "builtin:fizz"); + let mut forged_bestie = original_bestie.clone(); + forged_bestie.name = "forged edit".to_string(); + + let saved = instances_for_save( + &[fizz.clone(), forged_bestie], + &[fizz.clone(), original_bestie.clone()], + false, + ); + + assert_eq!(saved.len(), 2); + assert!(saved.contains(&fizz)); + assert!(saved.contains(&original_bestie)); + assert!(!saved.iter().any(|record| record.name == "forged edit")); +} + #[test] fn migrate_persists_and_signals_stripping_when_keyring_reachable() { // Item 2: an inline key (residue from a prior keyring-unreachable save)