From 57927b5ef3b819b1e37438b8f96c4ffb54c7ce09 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 15:40:10 -0600 Subject: [PATCH 01/14] feat(desktop): activate NIP-AP behavioral defaults on definitions (B5 step 1) Carry respond_to/respond_to_allowlist/mcp_toolsets/parallelism on PersonaRecord in wire shape (kebab-case string), wire them through persona_from_event / persona_event_content / to_persona_view / into_agent_record, and copy them onto instances at mint time via resolve_mint_behavioral_defaults (explicit input wins over definition default; the only parse point for definition behavioral strings, failing loudly on unknown modes, empty allowlists, and out-of-range parallelism). Replaces the staging lock behavioral_defaults_are_staged_not_applied with behavioral_defaults_survive_record_round_trip, plus hash rows: quad-absent definitions serialize byte-identically to the reserved era (persona_content_hash stable, no drift badge), and spawn re-snapshot never clobbers an instance's own quad with a definition's absent quad. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/commands/agent_config.rs | 8 + desktop/src-tauri/src/commands/agents.rs | 50 +++++-- .../src/commands/personas/inbound_tests.rs | 12 ++ .../src-tauri/src/commands/personas/mod.rs | 4 + .../src/commands/personas/writeback.rs | 4 + .../src/managed_agents/agent_events.rs | 4 + .../config_bridge/reader_tests.rs | 4 + .../src/managed_agents/discovery/tests.rs | 8 + .../src/managed_agents/nest/tests.rs | 8 + .../src/managed_agents/persona_events.rs | 111 +++++++++++--- .../src-tauri/src/managed_agents/personas.rs | 4 + .../src/managed_agents/personas/tests.rs | 4 + .../src-tauri/src/managed_agents/readiness.rs | 4 + .../src/managed_agents/runtime/tests.rs | 8 + .../src/managed_agents/spawn_hash/tests.rs | 37 +++++ .../src/managed_agents/team_repair.rs | 8 + desktop/src-tauri/src/managed_agents/teams.rs | 12 ++ desktop/src-tauri/src/managed_agents/types.rs | 141 +++++++++++++++++- .../src/managed_agents/types/tests.rs | 99 ++++++++++++ 19 files changed, 493 insertions(+), 37 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index bb9e2e581f..f4b68c8fc0 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -526,6 +526,10 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, agent_command_override: None, persona_source_version: None, @@ -548,6 +552,10 @@ mod tests { source_team: None, source_team_persona_slug: None, env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 7282e3cc66..f0c2105cb4 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -532,10 +532,13 @@ pub async fn create_managed_agent( // Validate & normalize the respond-to allowlist BEFORE any side effects. // The harness has its own validator (buzz-acp/src/config.rs) but we want // to catch malformed input at the boundary so the agent never tries to - // start with a list that will crash it on launch. + // start with a list that will crash it on launch. The mode/allowlist + // pairing (and the definition-default fallback) is resolved later at the + // mint site via `resolve_mint_behavioral_defaults`, where the linked + // definition is in hand. let respond_to_allowlist = crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?; - if input.respond_to == crate::managed_agents::RespondTo::Allowlist + if input.respond_to == Some(crate::managed_agents::RespondTo::Allowlist) && respond_to_allowlist.is_empty() { return Err( @@ -742,13 +745,15 @@ pub async fn create_managed_agent( // (input.env_vars), and the live persona env is merged underneath at // read time (spawn / readiness / deploy) so persona credential edits // refresh on the next spawn like prompt/model/provider already do. - let persona_snapshot = requested_persona_id.as_deref().and_then(|pid| { + let linked_persona = requested_persona_id.as_deref().and_then(|pid| { load_personas(&app) .ok()? .into_iter() .find(|persona| persona.id == pid) - .map(|persona| crate::managed_agents::persona_events::persona_snapshot(&persona)) }); + let persona_snapshot = linked_persona + .as_ref() + .map(crate::managed_agents::persona_events::persona_snapshot); let snapshot_prompt = persona_snapshot .as_ref() .and_then(|s| s.system_prompt.clone()); @@ -756,6 +761,23 @@ pub async fn create_managed_agent( let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone()); let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone()); + // Mint-time behavioral quad: explicit input wins, then the linked + // definition's NIP-AP defaults, then client defaults. The ONLY parse + // point for definition behavioral strings — fails loudly on a bad + // mode/range instead of minting an agent the author didn't describe. + let minted = crate::managed_agents::resolve_mint_behavioral_defaults( + input.respond_to, + respond_to_allowlist.clone(), + input + .mcp_toolsets + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + input.parallelism, + linked_persona.as_ref(), + )?; + let record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), @@ -782,10 +804,7 @@ pub async fn create_managed_agent( // 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum. idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), - parallelism: input - .parallelism - .filter(|count| (1..=32).contains(count)) - .unwrap_or(DEFAULT_AGENT_PARALLELISM), + parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM), system_prompt: snapshot_prompt.or_else(|| { input .system_prompt @@ -811,12 +830,7 @@ pub async fn create_managed_agent( .map(str::to_string) }), persona_source_version: snapshot_source_version, - mcp_toolsets: input - .mcp_toolsets - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), + mcp_toolsets: minted.mcp_toolsets.clone(), // Provider agents are managed externally — force false. start_on_app_launch: if input.backend != BackendKind::Local { false @@ -841,8 +855,8 @@ pub async fn create_managed_agent( last_exit_code: None, last_error: None, last_error_code: None, - respond_to: input.respond_to, - respond_to_allowlist: respond_to_allowlist.clone(), + respond_to: minted.respond_to, + respond_to_allowlist: minted.respond_to_allowlist.clone(), display_name: None, slug: None, runtime: None, @@ -851,6 +865,10 @@ pub async fn create_managed_agent( is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: relay_mesh.clone(), }; diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index d8e71aaad8..f52e8b713a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -23,6 +23,10 @@ fn local_in_app() -> PersonaRecord { source_team: Some("team-1".to_string()), source_team_persona_slug: None, env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), } @@ -45,6 +49,10 @@ fn inbound_for(d_tag: &str, display_name: &str) -> PersonaRecord { source_team: None, source_team_persona_slug: Some(d_tag.to_string()), env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-06-01T00:00:00Z".to_string(), updated_at: "2025-06-01T00:00:00Z".to_string(), } @@ -171,6 +179,10 @@ fn local_agent() -> ManagedAgentRecord { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 76230b5d00..4ced257101 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -207,6 +207,10 @@ pub async fn create_persona( source_team: None, source_team_persona_slug: None, env_vars: input.env_vars, + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: now.clone(), updated_at: now, }; diff --git a/desktop/src-tauri/src/commands/personas/writeback.rs b/desktop/src-tauri/src/commands/personas/writeback.rs index 03db967630..e5d4fa9618 100644 --- a/desktop/src-tauri/src/commands/personas/writeback.rs +++ b/desktop/src-tauri/src/commands/personas/writeback.rs @@ -279,6 +279,10 @@ mod writeback_tests { source_team: Some("team-1".to_string()), source_team_persona_slug: Some("paul".to_string()), env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), } diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 01d186cc09..e727b2e06f 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -213,6 +213,10 @@ mod tests { is_active: false, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } 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 dc5a5e287d..1488ecc973 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 @@ -85,6 +85,10 @@ fn test_record() -> ManagedAgentRecord { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, agent_command_override: None, persona_source_version: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 8d649dca96..260bc9e3b8 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -201,6 +201,10 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), } @@ -271,6 +275,10 @@ fn record_with( is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a81c5b09db..6f3d37e0a2 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -425,6 +425,10 @@ fn make_persona(id: &str, display_name: &str) -> PersonaRecord { source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: String::new(), updated_at: String::new(), } @@ -479,6 +483,10 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index d9722e2293..6be2dbda9a 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -193,6 +193,10 @@ pub fn persona_from_event(event: &nostr::Event) -> Result source_team: None, source_team_persona_slug: Some(d_tag), env_vars: BTreeMap::new(), + respond_to: content.respond_to, + respond_to_allowlist: content.respond_to_allowlist, + mcp_toolsets: content.mcp_toolsets, + parallelism: content.parallelism, created_at: created_at.clone(), updated_at: created_at, }) @@ -300,14 +304,15 @@ pub fn persona_event_content(record: &PersonaRecord) -> PersonaEventContent { model: record.model.clone(), provider: record.provider.clone(), name_pool: record.name_pool.clone(), - // NIP-AP behavioral defaults: RESERVED this release — parsed at the - // wire layer but not yet carried on PersonaRecord or applied at - // instance creation (that lands with the create-path unification). - // Guarded by `behavioral_defaults_are_staged_not_applied`. - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, + // NIP-AP behavioral defaults: live since the create-path unification + // (B5) — carried on PersonaRecord in wire shape and copied verbatim. + // Quad-absent records serialize identically to the reserved era, so + // persona_content_hash is stable across the activation (guarded by + // `quad_absent_definition_hash_stable_across_activation`). + respond_to: record.respond_to.clone(), + respond_to_allowlist: record.respond_to_allowlist.clone(), + mcp_toolsets: record.mcp_toolsets.clone(), + parallelism: record.parallelism, } } @@ -416,6 +421,10 @@ mod tests { source_team: None, source_team_persona_slug: Some("test-slug".to_string()), env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), } @@ -620,6 +629,10 @@ mod tests { source_team: None, source_team_persona_slug: None, env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), }; @@ -646,6 +659,10 @@ mod tests { source_team: Some("team-1".to_string()), source_team_persona_slug: None, env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), }; @@ -694,26 +711,76 @@ mod tests { .all(|t| t.as_slice().first().map(String::as_str) != Some("e"))); } - /// NIP-AP behavioral defaults are RESERVED this release: the wire type - /// parses them (foreign data survives deserialization) but the local - /// projection does not emit them and PersonaRecord does not carry them. - /// This test locks the staged behavior so activating the fields later is - /// a deliberate act — and documents that a foreign definition's - /// behavioral values do NOT survive a local edit-and-republish cycle yet. + /// NIP-AP behavioral defaults are LIVE since B5 (create-path + /// unification): the wire fields are carried on PersonaRecord in wire + /// shape and re-emitted verbatim by the projection — a foreign + /// definition's behavioral values now survive a local + /// edit-and-republish cycle. This test replaces + /// `behavioral_defaults_are_staged_not_applied` (the staging lock), + /// whose deliberate removal was pinned in the B5 review gates. #[test] - fn behavioral_defaults_are_staged_not_applied() { + fn behavioral_defaults_survive_record_round_trip() { const FOREIGN: &str = r#"{"display_name":"F","system_prompt":"p","respond_to":"anyone","respond_to_allowlist":["deadbeef"],"mcp_toolsets":"default","parallelism":4}"#; let parsed: PersonaEventContent = serde_json::from_str(FOREIGN).unwrap(); // Wire layer preserves the fields... assert_eq!(parsed.respond_to.as_deref(), Some("anyone")); assert_eq!(parsed.parallelism, Some(4)); - // ...but the record round-trip drops them (staged, not applied). + // ...and the record round-trip now carries them through. let record = persona_from_event_content_for_test(parsed); let reprojected = persona_event_content(&record); - assert_eq!(reprojected.respond_to, None); - assert_eq!(reprojected.respond_to_allowlist, Vec::::new()); - assert_eq!(reprojected.mcp_toolsets, None); - assert_eq!(reprojected.parallelism, None); + assert_eq!(reprojected.respond_to.as_deref(), Some("anyone")); + assert_eq!(reprojected.respond_to_allowlist, vec!["deadbeef"]); + assert_eq!(reprojected.mcp_toolsets.as_deref(), Some("default")); + assert_eq!(reprojected.parallelism, Some(4)); + } + + /// B5 hash row 1: a quad-absent definition's content bytes — and + /// therefore `persona_content_hash` — are identical before and after + /// quad activation. Pre-activation the projection hardcoded `None`; + /// post-activation it copies the record's (absent) quad. Both serialize + /// to the same bytes via `skip_serializing_if`, so no drift badge flips + /// and no republish wave fires for quad-absent definitions. + #[test] + fn quad_absent_definition_hash_stable_across_activation() { + let record = PersonaRecord { + id: "quad-absent".to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: Some("goose".to_string()), + model: Some("gpt-oss".to_string()), + provider: None, + name_pool: vec!["nib".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + let live = persona_event_content(&record); + // The reserved-era projection: identical fields, quad hardcoded off. + let reserved_era = PersonaEventContent { + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + ..live.clone() + }; + assert_eq!( + serde_json::to_string(&live).unwrap(), + serde_json::to_string(&reserved_era).unwrap(), + "quad-absent projection must serialize byte-identically to the reserved era" + ); + assert_eq!( + persona_content_hash(&live), + persona_content_hash(&reserved_era) + ); } /// Test-only bridge: build a PersonaRecord from parsed content the same @@ -733,6 +800,10 @@ mod tests { source_team: None, source_team_persona_slug: None, env_vars: BTreeMap::new(), + respond_to: content.respond_to, + respond_to_allowlist: content.respond_to_allowlist, + mcp_toolsets: content.mcp_toolsets, + parallelism: content.parallelism, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), } diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 3e88661580..ccaf4f31f1 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -237,6 +237,10 @@ fn built_in_persona_records(now: &str) -> Vec { source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: now.to_string(), updated_at: now.to_string(), }) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index b4e5764dd4..0c10d6e765 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -22,6 +22,10 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2026-03-19T00:00:00Z".to_string(), updated_at: "2026-03-19T00:00:00Z".to_string(), } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c3904c238f..aff4bf17ae 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1179,6 +1179,10 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, }; diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 626ceb4379..bfb7e1cbd2 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -170,6 +170,10 @@ fn fixture( is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } @@ -287,6 +291,10 @@ fn persona_with_provider( source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), } diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 7ff4fe0e25..b7d3f4910b 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -51,6 +51,10 @@ fn record() -> ManagedAgentRecord { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } @@ -70,6 +74,10 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> PersonaRecord { source_team: None, source_team_persona_slug: None, env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "now".into(), updated_at: "now".into(), } @@ -302,3 +310,32 @@ fn non_spawn_bookkeeping_fields_do_not_change_hash() { spawn_config_hash(&edited, &[], "wss://ws.example") ); } + +#[test] +fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { + // B5 hash row 3: the prospective re-snapshot copies ONLY + // prompt/model/provider/env from the linked definition. An instance + // whose owner hand-set respond_to/allowlist/parallelism/toolsets must + // hash identically whether or not its definition carries a quad — + // activation of the definition-level defaults must never reach through + // spawn and overwrite instance state. + let quadless_definition = vec![persona("p1", Some("goose"), "Persona prompt.")]; + + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.respond_to = RespondTo::Allowlist; + rec.respond_to_allowlist = vec!["a".repeat(64)]; + rec.parallelism = 4; + rec.mcp_toolsets = Some("default,canvas".into()); + + let mut definition_with_quad = quadless_definition.clone(); + definition_with_quad[0].respond_to = Some("anyone".into()); + definition_with_quad[0].parallelism = Some(8); + definition_with_quad[0].mcp_toolsets = Some("default".into()); + + assert_eq!( + spawn_config_hash(&rec, &quadless_definition, "wss://ws.example"), + spawn_config_hash(&rec, &definition_with_quad, "wss://ws.example"), + "definition quad must not leak into the spawn hash of an existing instance" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index 7777cb8722..88ecc2e852 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -249,6 +249,10 @@ mod tests { source_team: source_team.map(|s| s.to_string()), source_team_persona_slug: slug.map(|s| s.to_string()), env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), } @@ -319,6 +323,10 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_mcp_toolsets: None, + definition_parallelism: None, relay_mesh: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 33de8c7f45..25ec767430 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -295,6 +295,10 @@ pub fn import_team_from_directory( env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( p.runtime_env_vars.iter().cloned(), ), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: now.clone(), updated_at: now.clone(), }) @@ -530,6 +534,10 @@ pub fn sync_team_from_dir( env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( dir_persona.runtime_env_vars.iter().cloned(), ), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: now.clone(), updated_at: now.clone(), }; @@ -784,6 +792,10 @@ mod tests { source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2026-03-20T00:00:00Z".to_string(), updated_at: "2026-03-20T00:00:00Z".to_string(), } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 33e715a207..1a018f6327 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -63,6 +63,19 @@ pub struct PersonaRecord { /// Stored as a BTreeMap for deterministic on-disk ordering. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, + /// NIP-AP behavioral defaults, stored in WIRE shape (kebab-case string, + /// not the `RespondTo` enum) so `persona_event_content` is a verbatim + /// copy and quad-absent records serialize byte-identically to the + /// pre-activation era. Copied onto instances at mint time only — spawn + /// re-snapshot never touches them. Validated at the instance boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_toolsets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, pub created_at: String, pub updated_at: String, } @@ -121,6 +134,10 @@ impl PersonaRecord { is_active: self.is_active, source_team: self.source_team, source_team_persona_slug: self.source_team_persona_slug, + definition_respond_to: self.respond_to, + definition_respond_to_allowlist: self.respond_to_allowlist, + definition_mcp_toolsets: self.mcp_toolsets, + definition_parallelism: self.parallelism, relay_mesh: None, } } @@ -150,6 +167,10 @@ impl ManagedAgentRecord { source_team: self.source_team.clone(), source_team_persona_slug: self.source_team_persona_slug.clone(), env_vars: self.env_vars.clone(), + respond_to: self.definition_respond_to.clone(), + respond_to_allowlist: self.definition_respond_to_allowlist.clone(), + mcp_toolsets: self.definition_mcp_toolsets.clone(), + parallelism: self.definition_parallelism, created_at: self.created_at.clone(), updated_at: self.updated_at.clone(), }) @@ -337,6 +358,22 @@ pub struct ManagedAgentRecord { /// definition's slug within its source team. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_team_persona_slug: Option, + /// NIP-AP definition-level behavioral defaults, absorbed from + /// `PersonaRecord` in WIRE shape (kebab-case string / optional u32), + /// distinct from the instance-side `respond_to`/`respond_to_allowlist`/ + /// `parallelism` fields above: these are what a *definition* advertises + /// and are copied onto instances at mint time only. Wire shape (not the + /// `RespondTo` enum) so absent-ness and unknown future mode strings + /// round-trip byte-identically through the store — parsed/validated + /// solely at the mint boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_respond_to: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub definition_respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_mcp_toolsets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_parallelism: Option, /// Typed marker for relay-mesh agents. `Some(_)` means this agent runs its /// inference through Buzz's relay-mesh local endpoint; the `model_ref` is /// the served model id to route to. `None` is a normal agent. @@ -487,8 +524,12 @@ pub struct CreateManagedAgentRequest { pub start_on_app_launch: bool, #[serde(default)] pub backend: BackendKind, + /// `None` = caller expressed no preference: the definition's + /// `respond_to` default applies when linked, `RespondTo::default()` + /// otherwise. `Some` is an explicit instance-level choice and always + /// wins over the definition default. #[serde(default)] - pub respond_to: RespondTo, + pub respond_to: Option, /// Raw allowlist as received from the frontend. Validated and normalized /// before being written to the record. #[serde(default)] @@ -828,6 +869,22 @@ impl RespondTo { Self::Anyone => "anyone", } } + + /// Parse the NIP-AP wire string. Definitions carry `respond_to` as + /// opaque data everywhere else; this is the single parse boundary + /// (instance mint), and an unrecognized mode fails LOUDLY here rather + /// than silently defaulting — a typo'd definition must not mint an + /// agent with a different audience than its author intended. + pub fn parse_wire(value: &str) -> Result { + match value { + "owner-only" => Ok(Self::OwnerOnly), + "allowlist" => Ok(Self::Allowlist), + "anyone" => Ok(Self::Anyone), + other => Err(format!( + "definition respond_to '{other}' is not a recognized mode (expected 'owner-only', 'allowlist', or 'anyone')" + )), + } + } } /// Validate and normalize a respond-to allowlist. @@ -857,5 +914,87 @@ pub fn validate_respond_to_allowlist(input: &[String]) -> Result, St Ok(out) } +/// The behavioral fields resolved for a new instance at mint time. +#[derive(Debug, PartialEq, Eq)] +pub struct MintBehavioralDefaults { + pub respond_to: RespondTo, + pub respond_to_allowlist: Vec, + pub mcp_toolsets: Option, + /// Validated (1..=32) when present; caller applies its own default. + pub parallelism: Option, +} + +/// Resolve the NIP-AP behavioral quad for a new instance: explicit input +/// wins, then the linked definition's defaults, then client defaults. +/// +/// This is the ONLY place definition behavioral strings are parsed — an +/// unrecognized `respond_to` mode or out-of-range `parallelism` on a +/// definition fails the mint loudly instead of silently substituting a +/// default the definition author did not choose. The empty-allowlist guard +/// fires here too, because inbound definitions bypass the dialog entirely. +/// +/// `input_allowlist` must already be normalized via +/// [`validate_respond_to_allowlist`]; the definition's allowlist is +/// validated here since it arrives from the wire. +pub fn resolve_mint_behavioral_defaults( + input_respond_to: Option, + input_allowlist: Vec, + input_mcp_toolsets: Option, + input_parallelism: Option, + definition: Option<&PersonaRecord>, +) -> Result { + let (respond_to, respond_to_allowlist) = match input_respond_to { + // Explicit instance-level choice: the definition default is ignored + // wholesale (mode AND list travel together). + Some(mode) => (mode, input_allowlist), + None => match definition.and_then(|d| d.respond_to.as_deref()) { + Some(wire) => { + let mode = RespondTo::parse_wire(wire)?; + let list = if input_allowlist.is_empty() { + validate_respond_to_allowlist( + definition + .map(|d| d.respond_to_allowlist.as_slice()) + .unwrap_or(&[]), + ) + .map_err(|e| format!("definition respond-to allowlist is invalid: {e}"))? + } else { + input_allowlist + }; + (mode, list) + } + None => (RespondTo::default(), input_allowlist), + }, + }; + if respond_to == RespondTo::Allowlist && respond_to_allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let non_blank = |v: Option| v.filter(|s| !s.trim().is_empty()); + let mcp_toolsets = non_blank(input_mcp_toolsets) + .or_else(|| non_blank(definition.and_then(|d| d.mcp_toolsets.clone()))); + + let parallelism = match input_parallelism { + Some(count) => Some(count), + None => match definition.and_then(|d| d.parallelism) { + Some(count) if (1..=32).contains(&count) => Some(count), + Some(count) => { + return Err(format!( + "definition parallelism {count} is out of range (must be between 1 and 32)" + )) + } + None => None, + }, + }; + + Ok(MintBehavioralDefaults { + respond_to, + respond_to_allowlist, + mcp_toolsets, + parallelism, + }) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 752a7a04df..d1fa5e98d6 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -485,6 +485,10 @@ fn sample_persona() -> PersonaRecord { source_team: Some("team-1".to_string()), source_team_persona_slug: Some("helper".to_string()), env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-02T00:00:00Z".to_string(), } @@ -534,3 +538,98 @@ fn empty_prompt_folds_to_none() { persona.system_prompt = String::new(); assert_eq!(persona.into_agent_record().system_prompt, None); } + +// ── Mint-time behavioral defaults (B5 quad activation) ────────────────────── + +use super::resolve_mint_behavioral_defaults; + +fn quad_definition(respond_to: &str, allowlist: Vec<&str>) -> PersonaRecord { + let mut persona = sample_persona(); + persona.respond_to = Some(respond_to.to_string()); + persona.respond_to_allowlist = allowlist.into_iter().map(str::to_string).collect(); + persona.mcp_toolsets = Some("default,canvas".to_string()); + persona.parallelism = Some(8); + persona +} + +#[test] +fn mint_explicit_input_wins_over_definition() { + let definition = quad_definition("anyone", vec![]); + let minted = resolve_mint_behavioral_defaults( + Some(RespondTo::OwnerOnly), + Vec::new(), + Some("default".to_string()), + Some(2), + Some(&definition), + ) + .unwrap(); + assert_eq!(minted.respond_to, RespondTo::OwnerOnly); + assert_eq!(minted.mcp_toolsets.as_deref(), Some("default")); + assert_eq!(minted.parallelism, Some(2)); +} + +#[test] +fn mint_copies_definition_quad_when_input_silent() { + let allow = "a".repeat(64); + let definition = quad_definition("allowlist", vec![&allow]); + let minted = + resolve_mint_behavioral_defaults(None, Vec::new(), None, None, Some(&definition)).unwrap(); + assert_eq!(minted.respond_to, RespondTo::Allowlist); + assert_eq!(minted.respond_to_allowlist, vec![allow]); + assert_eq!(minted.mcp_toolsets.as_deref(), Some("default,canvas")); + assert_eq!(minted.parallelism, Some(8)); +} + +#[test] +fn mint_without_definition_or_input_uses_client_defaults() { + let minted = resolve_mint_behavioral_defaults(None, Vec::new(), None, None, None).unwrap(); + assert_eq!(minted.respond_to, RespondTo::default()); + assert!(minted.respond_to_allowlist.is_empty()); + assert_eq!(minted.mcp_toolsets, None); + assert_eq!(minted.parallelism, None); +} + +#[test] +fn mint_fails_loudly_on_unknown_definition_respond_to() { + // A typo'd mode must never silently become owner-only — the definition + // author intended SOMETHING, and guessing which thing is the one wrong + // move. The error must carry the offending string. + let definition = quad_definition("allowlst", vec![]); + let err = resolve_mint_behavioral_defaults(None, Vec::new(), None, None, Some(&definition)) + .unwrap_err(); + assert!( + err.contains("allowlst"), + "error must name the bad mode: {err}" + ); +} + +#[test] +fn mint_fails_loudly_on_empty_definition_allowlist() { + // Inbound definitions bypass the dialog guard entirely — the mint + // boundary is the backstop against a crash-looping instance. + let definition = quad_definition("allowlist", vec![]); + let err = resolve_mint_behavioral_defaults(None, Vec::new(), None, None, Some(&definition)) + .unwrap_err(); + assert!( + err.contains("at least one pubkey"), + "unexpected error: {err}" + ); +} + +#[test] +fn mint_fails_loudly_on_out_of_range_definition_parallelism() { + let mut definition = quad_definition("anyone", vec![]); + definition.parallelism = Some(64); + let err = resolve_mint_behavioral_defaults(None, Vec::new(), None, None, Some(&definition)) + .unwrap_err(); + assert!(err.contains("64"), "error must name the bad value: {err}"); +} + +#[test] +fn mint_normalizes_definition_allowlist_from_wire() { + let upper = "A".repeat(64); + let definition = quad_definition("allowlist", vec![&upper]); + let minted = + resolve_mint_behavioral_defaults(None, Vec::new(), None, None, Some(&definition)).unwrap(); + assert_eq!(minted.respond_to_allowlist, vec!["a".repeat(64)]); +} From f7472a21215b0b91c3c6b230b5b37e423faf5d67 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 15:47:24 -0600 Subject: [PATCH 02/14] fix(desktop): apply inbound quad edits on the persona match branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_inbound_persona's match arm copied every projected field except the behavioral quad, so a remote quad edit never landed on a device that already held the definition — its next reconcile would republish the stale quad over the edit, permanently. Copy all four fields like the other projected fields; quad-absent inbound clears them, matching prompt/model semantics. Caught by Pinky's step-1 review. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/commands/personas/inbound_tests.rs | 30 +++++++++++++++++++ .../src-tauri/src/commands/personas/mod.rs | 4 +++ 2 files changed, 34 insertions(+) diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index f52e8b713a..08d52a75b5 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -77,6 +77,36 @@ fn in_app_persona_matches_existing_uuid_and_patches() { assert_eq!(p.created_at, "2025-01-01T00:00:00Z"); } +#[test] +fn inbound_quad_edit_applies_to_existing_matched_record() { + // B5 quad activation: a remote quad edit must land on the MATCH branch, + // not just the insert branch — otherwise device B keeps its stale quad + // and its next reconcile republishes it over device A's edit, and the + // two devices never converge (permanent ping-pong). + let mut local = local_in_app(); + local.respond_to = Some("owner-only".to_string()); + local.parallelism = Some(2); + let mut personas = vec![local]; + + let mut inbound = inbound_for(UUID, "Remote"); + inbound.respond_to = Some("allowlist".to_string()); + inbound.respond_to_allowlist = vec!["a".repeat(64)]; + inbound.mcp_toolsets = Some("default,canvas".to_string()); + inbound.parallelism = Some(8); + apply_inbound_persona(&mut personas, inbound); + + assert_eq!(personas.len(), 1, "no duplicate row"); + let p = &personas[0]; + assert_eq!(p.respond_to, Some("allowlist".to_string())); + assert_eq!(p.respond_to_allowlist, vec!["a".repeat(64)]); + assert_eq!(p.mcp_toolsets, Some("default,canvas".to_string())); + assert_eq!(p.parallelism, Some(8)); + // A quad-absent inbound also applies (clears), same as prompt/model. + apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote")); + assert_eq!(personas[0].respond_to, None); + assert_eq!(personas[0].parallelism, None); +} + #[test] fn re_received_in_app_persona_is_idempotent_no_duplicate() { let mut personas = vec![local_in_app()]; diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 4ced257101..bdb676aaab 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -725,6 +725,10 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: PersonaReco local.model = inbound.model; local.provider = inbound.provider; local.name_pool = inbound.name_pool; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + local.mcp_toolsets = inbound.mcp_toolsets; + local.parallelism = inbound.parallelism; local.updated_at = inbound.updated_at; } None => personas.push(inbound), From 0e61f25263a2964a2ee7a25a377e33367c2903fc Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 16:01:09 -0600 Subject: [PATCH 03/14] feat(desktop): backfill standalone agents into definitions (B5 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot-time migration (migration/backfill.rs, after the Phase-1A fold): every keyed record with persona_id None gets a key-less definition manufactured from its own settings — slug = agent pubkey (64-hex passes the NIP-AP slug grammar, collision-free), prompt present-even-if-empty (the old-reader heal source, pinned by a load-bearing test), env copied so later instances inherit a working config, instance quad copied up as the definition's behavioral defaults. The record links via persona_id + persona_source_version = the manufactured definition's content hash, so the drift badge starts clean. Safety rails per the B5 review gates: idempotent (linked records skip; second run is a no-op), .bak create-if-absent (a re-run never clobbers the pristine pre-migration backup), fail-loudly-per-record on slug collision. Hash row 2: spawn treats an empty prompt as no prompt — Some("") and None both spawn with BUZZ_ACP_SYSTEM_PROMPT absent, so the env write and spawn_config_hash now filter empty-to-absent identically. Without this, the re-snapshot's Some("") from a manufactured definition would flip the restart badge on every prompt-less standalone agent at upgrade. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/managed_agents/runtime.rs | 6 +- .../src/managed_agents/spawn_hash.rs | 10 +- desktop/src-tauri/src/migration.rs | 7 + desktop/src-tauri/src/migration/backfill.rs | 136 +++++++++ .../src-tauri/src/migration/backfill_tests.rs | 277 ++++++++++++++++++ 5 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/migration/backfill.rs create mode 100644 desktop/src-tauri/src/migration/backfill_tests.rs diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b5ffb9c8b1..9fc6d98ac1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1732,7 +1732,11 @@ pub fn spawn_agent_child( // persona) is what keeps a running agent pinned across restarts: a persona // edit reaches the agent only via delete+respawn, which rewrites the // snapshot. - let effective_prompt = record.system_prompt.clone(); + // An empty prompt IS no prompt: `Some("")` and `None` spawn identically + // (filter must match spawn_config_hash's, or the badge lies — see B5 + // backfill: manufactured definitions carry prompt-present-even-if-empty, + // and the re-snapshot must not flip the hash of a prompt-less record). + let effective_prompt = record.system_prompt.clone().filter(|p| !p.is_empty()); let effective_model = record.model.clone(); let effective_provider = record.provider.clone(); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index f17d9eea6c..806c70cb3c 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -91,7 +91,15 @@ pub(crate) fn spawn_config_hash( // resolved: a blank record relay spawns on the workspace relay, so a // workspace relay change must trip the badge. crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - record.system_prompt.hash(&mut hasher); + // Empty-filtered to mirror the spawn env write: `Some("")` and `None` + // both spawn with BUZZ_ACP_SYSTEM_PROMPT absent, so they must hash + // equal (B5 hash row 2 — backfilled prompt-less records re-snapshot to + // `Some("")` and must not trip the badge). + record + .system_prompt + .as_deref() + .filter(|p| !p.is_empty()) + .hash(&mut hasher); record.model.hash(&mut hasher); record.provider.hash(&mut hasher); record.auth_tag.hash(&mut hasher); diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 816b6b072f..efbc1e1fb8 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -156,6 +156,11 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) { // Post-fold readers of the runtime map (`load_persona_runtimes`) fall // back to the unified store's definitions. fold_personas_into_agent_store(app); + // B5: manufacture definitions for standalone agents AFTER the fold (so + // pre-existing definition slugs are present for collision checks) and + // before event sync republishes — the backfilled link is what flips the + // 30177 projection to its slim shape. + backfill_standalone_agents(app); if let Err(e) = crate::managed_agents::sync_team_personas(app) { eprintln!("buzz-desktop: sync-team-personas: {e}"); } @@ -1259,6 +1264,8 @@ pub use materialize::materialize_agent_runtimes; mod fold; pub use fold::fold_personas_into_agent_store; use fold::load_persona_runtimes; +mod backfill; +pub use backfill::backfill_standalone_agents; #[cfg(test)] #[path = "migration_test_support.rs"] diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs new file mode 100644 index 0000000000..98084e61cf --- /dev/null +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -0,0 +1,136 @@ +//! B5 (unified agent model): one-time backfill of standalone agents into +//! definition-linked records. Every keyed record with `persona_id: None` +//! gets a key-less definition manufactured from its own settings, retiring +//! the standalone-create pattern (Wes's Option 2: backfill, not grandfather). + +use std::path::Path; + +use crate::managed_agents::{ + persona_events::{persona_content_hash, persona_event_content}, + ManagedAgentRecord, +}; + +/// Manufacture definitions for standalone agents (B5 backfill). +/// +/// For each keyed record with `persona_id: None`, append a key-less +/// definition record snapshotting the agent's own config and link the agent +/// to it. Safety rails (pinned in the B5 review gates): +/// - **Idempotent**: linked records are skipped, so a second run is a no-op. +/// - **`.bak` create-if-absent**: the pre-migration backup is taken once and +/// never clobbered — a partial first run must not replace the pristine +/// backup with a half-migrated snapshot on re-run. +/// - **Fail loudly per record**: a record that cannot be backfilled (slug +/// collision) is logged and skipped; the rest proceed. +/// - **No behavior change**: the definition snapshots the record's own +/// values (prompt present-even-if-empty via `to_persona_view`'s +/// `unwrap_or_default`, env COPIED so later instances inherit a working +/// config, quad copied to the definition defaults) and the record gains +/// `persona_source_version` = the new definition's content hash, so +/// neither `spawn_config_hash` nor the drift badge moves. +/// +/// The manufactured definition's slug is the agent's pubkey: 64-hex passes +/// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys +/// are unique, so the coordinate is collision-free by construction. +pub fn backfill_standalone_agents(app: &tauri::AppHandle) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match backfill_standalone_agents_in_dir(&base_dir) { + Ok(0) => {} + Ok(backfilled) => { + eprintln!( + "buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions" + ); + } + Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"), + } +} + +/// Core backfill logic, decoupled from the Tauri `AppHandle` for testing. +/// Returns the number of records backfilled (0 = nothing to do). +fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { + let agents_path = base_dir.join("managed-agents.json"); + if !agents_path.exists() { + return Ok(0); + } + let content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + let mut all: Vec = serde_json::from_str(&content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + + let needs_backfill = + |record: &ManagedAgentRecord| !record.pubkey.is_empty() && record.persona_id.is_none(); + if !all.iter().any(needs_backfill) { + return Ok(0); + } + + // Pre-migration backup, taken ONCE: a re-run after a partial failure must + // not overwrite the pristine backup with a half-migrated snapshot. + let bak_path = base_dir.join("managed-agents.json.pre-backfill.bak"); + if !bak_path.exists() { + std::fs::write(&bak_path, &content) + .map_err(|e| format!("failed to write pre-backfill backup: {e}"))?; + } + + let existing_slugs: std::collections::HashSet = + all.iter().filter_map(|r| r.slug.clone()).collect(); + + let mut manufactured: Vec = Vec::new(); + let mut backfilled = 0usize; + for record in all.iter_mut().filter(|r| needs_backfill(r)) { + // Pubkeys are unique so this cannot fire against another manufactured + // definition — only against a pre-existing definition improbably + // slugged as this agent's pubkey. Fail loudly, skip, continue. + if existing_slugs.contains(&record.pubkey) { + eprintln!( + "buzz-desktop: standalone-backfill: slug collision for agent {} — skipped", + record.pubkey + ); + continue; + } + + // Snapshot the record's own config as a definition. Via the same + // fold path every definition takes: a temporary persona view of the + // record (prompt unwrap_or_default = present-even-if-empty — the + // heal source old devices hard-require) folded into a key-less + // definition record. Quad + env come along so future instances + // minted from this definition inherit a working config. + let mut view_source = record.clone(); + view_source.slug = Some(record.pubkey.clone()); + // Standalone agents have no definition-level quad — the INSTANCE + // fields are the author's intent; copy them up. + view_source.definition_respond_to = Some(record.respond_to.as_str().to_string()); + view_source.definition_respond_to_allowlist = record.respond_to_allowlist.clone(); + view_source.definition_mcp_toolsets = record.mcp_toolsets.clone(); + view_source.definition_parallelism = Some(record.parallelism); + let Some(persona_view) = view_source.to_persona_view() else { + eprintln!( + "buzz-desktop: standalone-backfill: agent {} produced no persona view — skipped", + record.pubkey + ); + continue; + }; + + // Link the record BEFORE computing the version so the hash covers the + // definition exactly as manufactured. + let source_version = persona_content_hash(&persona_event_content(&persona_view)); + let definition = persona_view.into_agent_record(); + record.persona_id = Some(record.pubkey.clone()); + record.persona_source_version = Some(source_version); + manufactured.push(definition); + backfilled += 1; + } + + if backfilled == 0 { + return Ok(0); + } + all.extend(manufactured); + let payload = serde_json::to_vec_pretty(&all) + .map_err(|e| format!("failed to serialize unified store: {e}"))?; + crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + Ok(backfilled) +} + +#[cfg(test)] +#[path = "backfill_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs new file mode 100644 index 0000000000..b3798c2309 --- /dev/null +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -0,0 +1,277 @@ +use super::backfill_standalone_agents_in_dir; +use crate::managed_agents::spawn_hash::spawn_config_hash; +use crate::managed_agents::{ManagedAgentRecord, PersonaRecord}; +use crate::migration::test_support::{read_agents_json, write_agents_json}; +use std::path::Path; + +fn standalone_agent_json(name: &str, pubkey: &str, prompt: Option<&str>) -> serde_json::Value { + serde_json::json!({ + "name": name, + "pubkey": pubkey, + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "parallelism": 4, + "system_prompt": prompt, + "model": "gpt-x", + "provider": "openai", + "respond_to": "anyone", + "env_vars": { "API_KEY": "secret" }, + "start_on_app_launch": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }) +} + +fn load_typed(dir: &Path) -> Vec { + let content = std::fs::read_to_string(dir.join("agents").join("managed-agents.json")).unwrap(); + serde_json::from_str(&content).unwrap() +} + +fn base(dir: &Path) -> std::path::PathBuf { + dir.join("agents") +} + +#[test] +fn backfill_links_standalone_agent_to_manufactured_definition() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json( + "Solo", + &pubkey, + Some("You are Solo.") + )]), + ); + + let backfilled = backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + assert_eq!(backfilled, 1); + + let records = load_typed(dir.path()); + assert_eq!(records.len(), 2, "instance + manufactured definition"); + + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); + assert!(instance.persona_source_version.is_some()); + + let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap(); + assert_eq!(definition.slug.as_deref(), Some(pubkey.as_str())); + assert_eq!(definition.system_prompt.as_deref(), Some("You are Solo.")); + assert_eq!(definition.model.as_deref(), Some("gpt-x")); + // Env COPIED (B5 pin): later instances inherit a working config. + assert_eq!( + definition.env_vars.get("API_KEY").map(String::as_str), + Some("secret") + ); + // Instance quad copied up as the definition's defaults. + assert_eq!(definition.definition_respond_to.as_deref(), Some("anyone")); + assert_eq!(definition.definition_parallelism, Some(4)); + + // The recorded version matches the definition's actual content hash — + // the drift badge starts clean. + let view = definition.to_persona_view().unwrap(); + let expected = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(&view), + ); + assert_eq!( + instance.persona_source_version.as_deref(), + Some(expected.as_str()) + ); +} + +#[test] +fn backfilled_definition_carries_prompt_present_even_if_empty() { + // LOAD-BEARING (B5 gates): old readers hard-fail on an absent prompt. A + // prompt-less backfilled definition would leave a wiped old device with + // no heal source, permanently. `PersonaRecord.system_prompt` is a plain + // String and the outbound projection wraps it in `Some` unconditionally + // — this row pins that chain against refactors. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "b".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]), + ); + + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + + let records = load_typed(dir.path()); + let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap(); + let view: PersonaRecord = definition.to_persona_view().unwrap(); + assert_eq!(view.system_prompt, "", "empty, not absent"); + let content = crate::managed_agents::persona_events::persona_event_content(&view); + assert_eq!( + content.system_prompt.as_deref(), + Some(""), + "wire projection must carry Some(\"\") — the old-reader heal source" + ); +} + +#[test] +fn backfill_of_promptless_record_keeps_spawn_hash_stable() { + // B5 hash row 2: pre-backfill the record hashes prompt None; post-backfill + // the prospective re-snapshot pulls Some("") from the manufactured + // definition. The spawn layer treats an empty prompt as no prompt (env + // absent either way), so the hash must not move — otherwise every + // prompt-less standalone agent lights the restart badge on upgrade. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "c".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]), + ); + + let pre_records = load_typed(dir.path()); + let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let hash_before = spawn_config_hash(pre_instance, &[], "wss://ws.example"); + + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + + let post_records = load_typed(dir.path()); + let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let personas: Vec = post_records + .iter() + .filter_map(|r| r.to_persona_view()) + .collect(); + let hash_after = spawn_config_hash(post_instance, &personas, "wss://ws.example"); + + assert_eq!( + hash_before, hash_after, + "backfill must not flip the restart badge for prompt-less agents" + ); +} + +#[test] +fn backfill_of_prompted_record_keeps_spawn_hash_stable() { + // The general no-behavior-change rail: a standalone agent WITH config + // must also hash identically across backfill (the definition snapshots + // the record's own values, so the re-snapshot writes back what is + // already there). + let dir = tempfile::tempdir().unwrap(); + let pubkey = "d".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json( + "Solo", + &pubkey, + Some("You are Solo.") + )]), + ); + + let pre_records = load_typed(dir.path()); + let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let hash_before = spawn_config_hash(pre_instance, &[], "wss://ws.example"); + + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + + let post_records = load_typed(dir.path()); + let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let personas: Vec = post_records + .iter() + .filter_map(|r| r.to_persona_view()) + .collect(); + let hash_after = spawn_config_hash(post_instance, &personas, "wss://ws.example"); + + assert_eq!(hash_before, hash_after); +} + +#[test] +fn second_run_is_a_no_op_and_preserves_pristine_backup() { + // B5 gates: double-run idempotence + create-if-absent .bak. Run 1 + // migrates; run 2 must change nothing and must NOT clobber the pristine + // pre-migration backup with a half-migrated snapshot. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json("Solo", &pubkey, Some("P"))]), + ); + let pristine = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let after_first = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 0, + "second run is a no-op" + ); + let after_second = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + assert_eq!(after_first, after_second, "store untouched by re-run"); + + let bak = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json.pre-backfill.bak")) + .unwrap(); + assert_eq!( + bak, pristine, + "backup is the PRE-migration state, never clobbered" + ); +} + +#[test] +fn definitions_and_linked_records_are_untouched() { + // Already-linked instances and existing definitions pass through + // byte-identical; a store with nothing to backfill takes no backup. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "f".repeat(64); + let mut linked = standalone_agent_json("Linked", &pubkey, Some("P")); + linked["persona_id"] = serde_json::json!("some-definition"); + write_agents_json(dir.path(), &serde_json::json!([linked])); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 0 + ); + assert!( + !base(dir.path()) + .join("managed-agents.json.pre-backfill.bak") + .exists(), + "no work, no backup" + ); + let records = read_agents_json(dir.path()); + assert_eq!(records.len(), 1, "nothing manufactured"); +} + +#[test] +fn slug_collision_fails_loudly_per_record_and_continues() { + // A pre-existing definition improbably slugged as an agent's pubkey: + // that record is skipped (logged), the rest proceed. + let dir = tempfile::tempdir().unwrap(); + let colliding = "1".repeat(64); + let clean = "2".repeat(64); + let mut definition = standalone_agent_json("Def", "", Some("P")); + definition["slug"] = serde_json::json!(colliding.clone()); + definition["pubkey"] = serde_json::json!(""); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition, + standalone_agent_json("Collides", &colliding, Some("P")), + standalone_agent_json("Clean", &clean, Some("P")), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1, + "collision skipped, clean record backfilled" + ); + let records = load_typed(dir.path()); + let collided = records.iter().find(|r| r.pubkey == colliding).unwrap(); + assert_eq!(collided.persona_id, None, "collided record left standalone"); + let clean_rec = records.iter().find(|r| r.pubkey == clean).unwrap(); + assert_eq!(clean_rec.persona_id.as_deref(), Some(clean.as_str())); +} From cc1547761081588c9f27e75a5a2df559ff065145 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 16:04:13 -0600 Subject: [PATCH 04/14] fix(desktop): share the spawn-effective prompt filter, exempt team packs Extract effective_spawn_prompt as the single source of truth for the Some("")/None collapse: the spawn env write and spawn_config_hash both call it, so the badge cannot disagree with what a spawn delivers. Team-pack records are exempt from the collapse (Pinky's wrinkle 1): buzz-acp inherits the pack persona prompt when BUZZ_ACP_SYSTEM_PROMPT is absent but treats set-but-empty as deliberate suppression, so for those records the two states are different spawns and must stay distinct in both the env write and the hash. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/managed_agents/runtime.rs | 10 ++-- .../src/managed_agents/spawn_hash.rs | 31 +++++++--- .../src/managed_agents/spawn_hash/tests.rs | 60 +++++++++++++++++++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 9fc6d98ac1..354e343070 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1732,11 +1732,11 @@ pub fn spawn_agent_child( // persona) is what keeps a running agent pinned across restarts: a persona // edit reaches the agent only via delete+respawn, which rewrites the // snapshot. - // An empty prompt IS no prompt: `Some("")` and `None` spawn identically - // (filter must match spawn_config_hash's, or the badge lies — see B5 - // backfill: manufactured definitions carry prompt-present-even-if-empty, - // and the re-snapshot must not flip the hash of a prompt-less record). - let effective_prompt = record.system_prompt.clone().filter(|p| !p.is_empty()); + // Prompt via the shared spawn-effective filter — the SAME function the + // config hash digests, so env write and badge cannot disagree (see + // `effective_spawn_prompt` for the Some("")/None collapse and the + // team-pack suppression exception). + let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); let effective_model = record.model.clone(); let effective_provider = record.provider.clone(); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 806c70cb3c..e369fd443e 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -33,6 +33,25 @@ use super::{ types::{ManagedAgentRecord, PersonaRecord}, }; +/// The prompt a spawn would actually deliver: `Some("")` collapses to `None` +/// because an empty BUZZ_ACP_SYSTEM_PROMPT is no prompt — EXCEPT for +/// team-pack records, where buzz-acp falls back to the pack persona's prompt +/// when the env var is absent, making set-but-empty a deliberate suppression. +/// +/// The single source of truth for the spawn env write AND the config hash: +/// both call this, so they cannot disagree (the hash's contract is "digest +/// what a spawn would actually run"). B5 hash row 2 depends on the collapse: +/// backfilled prompt-less records re-snapshot to `Some("")` from their +/// manufactured definition and must not trip the restart badge. +pub(crate) fn effective_spawn_prompt(record: &ManagedAgentRecord) -> Option { + let has_pack_fallback = + record.persona_team_dir.is_some() && record.persona_name_in_team.is_some(); + record + .system_prompt + .clone() + .filter(|p| has_pack_fallback || !p.is_empty()) +} + /// Digest the effective spawn configuration of `record` under the current /// `personas`, resolving a blank record relay against `workspace_relay`. /// Pure — no `AppHandle`, no disk, no keyring. @@ -91,15 +110,9 @@ pub(crate) fn spawn_config_hash( // resolved: a blank record relay spawns on the workspace relay, so a // workspace relay change must trip the badge. crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - // Empty-filtered to mirror the spawn env write: `Some("")` and `None` - // both spawn with BUZZ_ACP_SYSTEM_PROMPT absent, so they must hash - // equal (B5 hash row 2 — backfilled prompt-less records re-snapshot to - // `Some("")` and must not trip the badge). - record - .system_prompt - .as_deref() - .filter(|p| !p.is_empty()) - .hash(&mut hasher); + // Prompt via the shared spawn-effective filter (see its doc for the + // Some("")/None collapse and the team-pack exception). + effective_spawn_prompt(record).hash(&mut hasher); record.model.hash(&mut hasher); record.provider.hash(&mut hasher); record.auth_tag.hash(&mut hasher); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index b7d3f4910b..a524457f9e 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -339,3 +339,63 @@ fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { "definition quad must not leak into the spawn hash of an existing instance" ); } + +#[test] +fn empty_prompt_hashes_like_absent_prompt() { + // B5 hash row 2 foundation: Some("") and None spawn identically (env var + // absent either way), so they must hash equal — a backfilled prompt-less + // record re-snapshots to Some("") and must not trip the badge. + let mut absent = record(); + absent.system_prompt = None; + let mut empty = record(); + empty.system_prompt = Some(String::new()); + assert_eq!( + spawn_config_hash(&absent, &[], "wss://ws.example"), + spawn_config_hash(&empty, &[], "wss://ws.example"), + ); +} + +#[test] +fn team_pack_records_keep_empty_vs_absent_prompt_distinction() { + // Wrinkle-1 exception (Pinky): with BUZZ_ACP_PERSONA_PACK set, buzz-acp + // inherits the pack persona's prompt when the env var is ABSENT, while + // set-but-empty suppresses it. For team records the two states spawn + // differently, so they must hash differently. + let mut absent = record(); + absent.persona_team_dir = Some(std::path::PathBuf::from("/teams/alpha")); + absent.persona_name_in_team = Some("lep".into()); + absent.system_prompt = None; + + let mut empty = absent.clone(); + empty.system_prompt = Some(String::new()); + + assert_ne!( + spawn_config_hash(&absent, &[], "wss://ws.example"), + spawn_config_hash(&empty, &[], "wss://ws.example"), + "suppressed pack prompt is a different spawn than inherited pack prompt" + ); +} + +#[test] +fn effective_spawn_prompt_matches_hash_semantics() { + // The env write and the hash share effective_spawn_prompt — this row + // pins the helper's own semantics so a refactor of either caller cannot + // silently diverge from the contract. + let mut r = record(); + r.system_prompt = Some(String::new()); + assert_eq!( + effective_spawn_prompt(&r), + None, + "empty collapses to absent" + ); + r.system_prompt = Some("real".into()); + assert_eq!(effective_spawn_prompt(&r).as_deref(), Some("real")); + r.system_prompt = Some(String::new()); + r.persona_team_dir = Some(std::path::PathBuf::from("/teams/alpha")); + r.persona_name_in_team = Some("lep".into()); + assert_eq!( + effective_spawn_prompt(&r).as_deref(), + Some(""), + "team-pack set-but-empty survives (deliberate suppression)" + ); +} From 4dfac6caa968017d00f0564ac73845416cc0f116 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 16:14:04 -0600 Subject: [PATCH 05/14] fix(desktop): publish tombstones before replacements in the sync flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_pending_sync now orders kind:5 rows first (oldest-first within each group). Without this, a definition deleted in one session and resurrected by the B5 backfill on the next boot could publish its fresh 30175 before the pending tombstone — and the relay a-tag deletion soft-deletes every live row at the coordinate with no timestamp check, wiping the replacement and leaving devices divergent until the next content edit. Traced with Pinky during step-2 review. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/managed_agents/retention.rs | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 1ba5d14a5e..c57356178c 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -198,12 +198,20 @@ pub fn get_retained_personas( } /// Get all events marked as pending sync (not yet confirmed on relay). +/// +/// Tombstones (kind:5) sort FIRST: a delete retained in one session and its +/// coordinate's replacement retained in a later one (B5 backfill resurrecting +/// a deleted definition) must publish in that order — the relay's a-tag +/// deletion soft-deletes every live row at the coordinate with no timestamp +/// comparison, so a tombstone published AFTER the replacement would wipe it. +/// Within each group, oldest first for the same reason. pub fn get_pending_sync(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync FROM persona_events - WHERE pending_sync = 1", + WHERE pending_sync = 1 + ORDER BY (kind != 5), created_at ASC", ) .map_err(|e| format!("failed to prepare pending sync query: {e}"))?; @@ -630,4 +638,34 @@ mod tests { assert_eq!(row.created_at, 2000); assert!(!row.content.contains("Stale")); } + + #[test] + fn pending_sync_publishes_tombstones_before_replacements() { + // B5 resurrection race: a kind:5 retained in session N and the same + // coordinate's replacement 30175 retained on the next boot can sit + // pending together. The relay's a-tag deletion ignores timestamps, + // so the tombstone MUST publish first or it wipes the replacement. + let conn = test_db(); + let replacement = RetainedEvent { + kind: 30175, + created_at: 2000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &replacement).unwrap(); + let tombstone = RetainedEvent { + kind: 5, + d_tag: tombstone_retention_d_tag(30175, "test-persona"), + content: String::new(), + created_at: 1000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &tombstone).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].kind, 5, "tombstone first"); + assert_eq!(pending[1].kind, 30175, "replacement second"); + } } From 685cfe22ad4e358e15e1aff2e9dc5f4ded4f95ef Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 16:32:13 -0600 Subject: [PATCH 06/14] fix(desktop): defer replacements behind a failed tombstone in the sync flush The pending-sync ORDER BY puts kind:5 tombstones before the replacements that supersede them, but the flush is best-effort per row: a tombstone the relay rejects mid-sweep would be leapfrogged by its own replacement, and the tombstone landing on a later sweep would wipe it (the relay's a-tag deletion ignores created_at). Track failed tombstones within the sweep and defer any non-kind:5 row whose coordinate a failed tombstone covers; next sweep the ORDER BY restores tombstone-first ordering. The starvation direction is safe: a relay that persistently rejects the tombstone defers its replacement indefinitely, which fails toward stay-deleted (the user's intent), never toward wiping a live replacement. The deferral predicate is a pure fn, kind- and pubkey-qualified via tombstone_retention_d_tag so a coinciding slug under a different kind or another pubkey never spuriously defers, and kind:5 rows themselves are never deferred. Covered by predicate unit rows plus a stub-relay flush test asserting the deferred row stays pending_sync and is excluded from the flushed count while unrelated rows publish. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/managed_agents/persona_events.rs | 153 +++++++++++++++++- .../src-tauri/src/managed_agents/retention.rs | 70 ++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 6be2dbda9a..9403c51ca5 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -226,7 +226,8 @@ pub async fn flush_pending_events( state: &AppState, ) -> Result { use crate::managed_agents::retention::{ - get_pending_sync, get_retained_event, mark_synced, open_retention_db, + deferred_behind_failed_tombstone, get_pending_sync, get_retained_event, mark_synced, + open_retention_db, }; use nostr::JsonUtil; @@ -236,7 +237,12 @@ pub async fn flush_pending_events( }; // connection dropped before any .await let mut flushed = 0u32; + let mut failed_tombstones: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); for row in pending { + if deferred_behind_failed_tombstone(row.kind, &row.pubkey, &row.d_tag, &failed_tombstones) { + continue; // its tombstone failed this sweep; next sweep re-orders them + } // Re-read immediately before publishing; the row may have been edited // or deleted since the pending snapshot above. let current = { @@ -257,6 +263,9 @@ pub async fn flush_pending_events( .await .is_err() { + if current.kind == 5 { + failed_tombstones.insert((current.pubkey.clone(), current.d_tag.clone())); + } continue; // relay unreachable — stays pending for the next sweep } @@ -1047,4 +1056,146 @@ mod tests { "persona provider must win when persona has a value" ); } + + // Gated off Windows for the same reason as `archive::real_relay`: + // `build_app_state()` pulls native DLLs unavailable in the Windows CI + // runner. This stub-relay test is hermetic (localhost axum) otherwise. + #[cfg(not(target_os = "windows"))] + mod flush_barrier { + use super::*; + use crate::app_state::build_app_state; + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }; + use nostr::JsonUtil; + + /// Stub relay: `POST /events` rejects kind:5 with HTTP 500, accepts + /// everything else. Returns the HTTP base URL. + async fn spawn_stub_relay() -> String { + use axum::{http::StatusCode, routing::post, Router}; + + let app = Router::new().route( + "/events", + post(|body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + if event.get("kind").and_then(serde_json::Value::as_u64) == Some(5) { + return (StatusCode::INTERNAL_SERVER_ERROR, String::new()); + } + ( + StatusCode::OK, + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": true, + "message": "" + }) + .to_string(), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stub relay"); + let addr = listener.local_addr().expect("stub relay addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") + } + + fn retain_signed( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + kind: u32, + retention_d_tag: &str, + builder: nostr::EventBuilder, + created_at: i64, + ) { + let event = builder.sign_with_keys(keys).expect("sign test event"); + retain_event( + conn, + &RetainedEvent { + kind, + pubkey: keys.public_key().to_hex(), + d_tag: retention_d_tag.to_string(), + content: event.content.to_string(), + created_at, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .expect("retain test event"); + } + + /// The mid-sweep barrier: a tombstone the relay rejects must defer its + /// own replacement to the next sweep (still pending, not counted as + /// flushed) while unrelated rows in the same sweep publish normally. + /// Failing toward stay-deleted is the safe direction — the deferred + /// replacement can never be wiped by its own late tombstone. + #[tokio::test] + async fn failed_tombstone_defers_replacement_within_sweep() { + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + { + let conn = open_retention_db(&db_path).expect("open db"); + // Tombstone (publishes first, relay rejects it). + retain_signed( + &conn, + &keys, + 5, + &tombstone_retention_d_tag(KIND_PERSONA, "covered"), + build_persona_delete("covered", &pubkey).unwrap(), + 1000, + ); + // Its replacement at the same coordinate (must defer). + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "covered", + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") + .tags(vec![Tag::parse(["d", "covered"]).unwrap()]), + 2000, + ); + // Unrelated coordinate (must publish despite the barrier). + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "unrelated", + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") + .tags(vec![Tag::parse(["d", "unrelated"]).unwrap()]), + 1500, + ); + } + + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!(flushed, 1, "only the unrelated row publishes"); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = |kind: u32, d_tag: &str| { + get_retained_event(&conn, kind, &pubkey, d_tag) + .unwrap() + .unwrap() + }; + assert!( + row(5, &tombstone_retention_d_tag(KIND_PERSONA, "covered")).pending_sync, + "failed tombstone stays pending" + ); + assert!( + row(KIND_PERSONA, "covered").pending_sync, + "deferred replacement stays pending" + ); + assert!( + !row(KIND_PERSONA, "unrelated").pending_sync, + "unrelated row marked synced" + ); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index c57356178c..1c66c8b7b6 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -64,6 +64,26 @@ pub fn tombstone_retention_d_tag(target_kind: u32, d_tag: &str) -> String { format!("{target_kind}:{d_tag}") } +/// Whether a pending row must be deferred to the next sweep because a kind:5 +/// tombstone covering its coordinate failed to publish earlier in the same +/// sweep. +/// +/// `get_pending_sync` orders tombstones first so a deletion always reaches the +/// relay before the replacement that supersedes it — but the flush is +/// best-effort per row, so a tombstone that fails mid-sweep would otherwise be +/// leapfrogged by its own replacement and then wipe it on the next sweep. +/// Deferring the replacement restores the ordering guarantee: next sweep the +/// `ORDER BY` puts the tombstone first again. Kind:5 rows are never deferred. +pub fn deferred_behind_failed_tombstone( + kind: u32, + pubkey: &str, + d_tag: &str, + failed_tombstones: &std::collections::HashSet<(String, String)>, +) -> bool { + kind != 5 + && failed_tombstones.contains(&(pubkey.to_string(), tombstone_retention_d_tag(kind, d_tag))) +} + /// Upsert a persona event into the retention store. /// /// Only replaces if the new event has a newer or equal `created_at` (NIP-33 semantics). @@ -668,4 +688,54 @@ mod tests { assert_eq!(pending[0].kind, 5, "tombstone first"); assert_eq!(pending[1].kind, 30175, "replacement second"); } + + #[test] + fn deferral_predicate_is_kind_and_pubkey_qualified() { + // Mid-sweep barrier semantics: a failed tombstone defers ONLY the + // replacement at its exact coordinate — same target kind, same pubkey. + use std::collections::HashSet; + + let failed: HashSet<(String, String)> = HashSet::from([( + "abc123".to_string(), + tombstone_retention_d_tag(30175, "test-persona"), + )]); + + // The covered replacement defers. + assert!(deferred_behind_failed_tombstone( + 30175, + "abc123", + "test-persona", + &failed + )); + // Kind-qualified: a coinciding slug under a DIFFERENT kind is a + // distinct coordinate (the cross-kind collision the retention d-tag + // encoding exists to prevent) — never deferred. + assert!(!deferred_behind_failed_tombstone( + 30177, + "abc123", + "test-persona", + &failed + )); + // Never crosses pubkeys. + assert!(!deferred_behind_failed_tombstone( + 30175, + "other-key", + "test-persona", + &failed + )); + // Never defers kind:5 rows, even at a "matching" retention key. + assert!(!deferred_behind_failed_tombstone( + 5, + "abc123", + "test-persona", + &failed + )); + // Unrelated d-tags publish normally. + assert!(!deferred_behind_failed_tombstone( + 30175, + "abc123", + "other-persona", + &failed + )); + } } From 1dae8a9a3072f1dbb4c823136f7e37b78413722d Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 16:51:39 -0600 Subject: [PATCH 07/14] refactor(desktop): split persona_events tests and pending-retention helpers into modules The B5 test additions pushed persona_events.rs (1202) and commands/personas/mod.rs (1001) past the 1000-line file-size guard. Fix the cause instead of adding exceptions: move the persona_events test module to persona_events/tests.rs (the existing spawn_hash/types pattern) and extract the retention-enqueue helpers (retain_persona_pending / tombstone_persona_pending) into commands/personas/pending.rs. Pure moves, no behavior change; visibility widened only as far as the existing callers (commands::teams) require. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/commands/personas/mod.rs | 120 +-- .../src/commands/personas/pending.rs | 130 +++ .../src/managed_agents/persona_events.rs | 789 +----------------- .../managed_agents/persona_events/tests.rs | 785 +++++++++++++++++ 4 files changed, 919 insertions(+), 905 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/pending.rs create mode 100644 desktop/src-tauri/src/managed_agents/persona_events/tests.rs diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index bdb676aaab..994ac87879 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -32,123 +32,9 @@ fn trim_optional(value: Option) -> Option { }) } -/// Retain a freshly authored persona event in the local store, flagged for -/// relay sync. Called inside a command's `managed_agents_store_lock`-held body -/// after `save_personas`; the background flush loop publishes it out-of-band. -/// -/// The event is signed with the owner keys at call time, so its `created_at` -/// is `now` — newer than any prior retained row, clearing the upsert's -/// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, -/// which is the sole publisher. Best-effort: a failure here is logged and -/// swallowed so a retention hiccup never blocks the disk-authoritative write. -/// -/// Unlike `retain_managed_agent_pending`, this has no projection-equality -/// short-circuit: personas have no start/stop runtime churn, so a republish -/// only happens on a genuine create/update/delete user edit (`set_persona_active` -/// does not retain, so the local-only `is_active` toggle never republishes, and -/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The -/// guard is intentionally omitted. -fn retain_persona_pending(app: &AppHandle, state: &AppState, persona: &PersonaRecord) { - use crate::managed_agents::{ - managed_agents_base_dir, - persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let d_tag = persona_d_tag(persona); - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - // Monotonic created_at: read the retained head for this coordinate - // and bump past it (NIP-AP step 3) so a same-second edit supersedes. - let prior = - get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? - .map(|row| row.created_at); - let event = build_persona_event(persona)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona event: {e}"))?; - (keys.public_key().to_hex(), event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_PERSONA, - pubkey, - d_tag, - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-retain: {e}"); - } -} - -/// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both -/// inside the `managed_agents_store_lock`-held delete body. -/// -/// PURGE IN: `delete_retained_event` removes the persona's `(30175, pubkey, -/// d_tag)` row. Running it under the same lock that serializes `retain_event` -/// closes the same-second resurrect race — a concurrent edit can't re-insert a -/// pending persona row after the tombstone is queued. -/// -/// PUBLISH OUT: the kind:5 tombstone is retained at its own coordinate `(5, -/// pubkey, d_tag)` (distinct from the purged persona row) with `pending_sync = -/// 1`; the flush loop publishes it. Best-effort: a failure is logged and -/// swallowed so a retention hiccup never blocks the disk-authoritative delete. -pub(super) fn tombstone_persona_pending(app: &AppHandle, state: &AppState, d_tag: &str) { - use crate::managed_agents::{ - managed_agents_base_dir, - persona_events::build_persona_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, - }; - use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; - - const KIND_DELETE: u32 = 5; - - let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_persona_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - // Purge the persona row first so an unpublished edit can never resurrect - // it after the tombstone publishes. - delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-tombstone: {e}"); - } -} +mod pending; +use pending::retain_persona_pending; +pub(super) use pending::tombstone_persona_pending; #[tauri::command] pub async fn list_personas(app: AppHandle) -> Result, String> { diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs new file mode 100644 index 0000000000..a01d380871 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -0,0 +1,130 @@ +//! Retention-store enqueue helpers for owner-authored persona writes: retain +//! a pending 30175 on create/update, purge + tombstone on delete. The flush +//! loop (`flush_pending_events`) is the sole publisher. + +use tauri::AppHandle; + +use crate::app_state::AppState; +use crate::managed_agents::PersonaRecord; + +/// Retain a freshly authored persona event in the local store, flagged for +/// relay sync. Called inside a command's `managed_agents_store_lock`-held body +/// after `save_personas`; the background flush loop publishes it out-of-band. +/// +/// The event is signed with the owner keys at call time, so its `created_at` +/// is `now` — newer than any prior retained row, clearing the upsert's +/// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, +/// which is the sole publisher. Best-effort: a failure here is logged and +/// swallowed so a retention hiccup never blocks the disk-authoritative write. +/// +/// Unlike `retain_managed_agent_pending`, this has no projection-equality +/// short-circuit: personas have no start/stop runtime churn, so a republish +/// only happens on a genuine create/update/delete user edit (`set_persona_active` +/// does not retain, so the local-only `is_active` toggle never republishes, and +/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The +/// guard is intentionally omitted. +pub(super) fn retain_persona_pending(app: &AppHandle, state: &AppState, persona: &PersonaRecord) { + use crate::managed_agents::{ + managed_agents_base_dir, + persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let d_tag = persona_d_tag(persona); + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let (pubkey, event) = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + // Monotonic created_at: read the retained head for this coordinate + // and bump past it (NIP-AP step 3) so a same-second edit supersedes. + let prior = + get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? + .map(|row| row.created_at); + let event = build_persona_event(persona)? + .custom_created_at(monotonic_created_at(prior)) + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign persona event: {e}"))?; + (keys.public_key().to_hex(), event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_PERSONA, + pubkey, + d_tag, + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: persona-retain: {e}"); + } +} + +/// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both +/// inside the `managed_agents_store_lock`-held delete body. +/// +/// PURGE IN: `delete_retained_event` removes the persona's `(30175, pubkey, +/// d_tag)` row. Running it under the same lock that serializes `retain_event` +/// closes the same-second resurrect race — a concurrent edit can't re-insert a +/// pending persona row after the tombstone is queued. +/// +/// PUBLISH OUT: the kind:5 tombstone is retained at its own coordinate `(5, +/// pubkey, d_tag)` (distinct from the purged persona row) with `pending_sync = +/// 1`; the flush loop publishes it. Best-effort: a failure is logged and +/// swallowed so a retention hiccup never blocks the disk-authoritative delete. +pub(in crate::commands) fn tombstone_persona_pending( + app: &AppHandle, + state: &AppState, + d_tag: &str, +) { + use crate::managed_agents::{ + managed_agents_base_dir, + persona_events::build_persona_delete, + retention::{ + delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + + let result = (|| -> Result<(), String> { + let (pubkey, event) = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let pubkey = keys.public_key().to_hex(); + let event = build_persona_delete(d_tag, &pubkey)? + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; + (pubkey, event) + }; + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + // Purge the persona row first so an unpublished edit can never resurrect + // it after the tombstone publishes. + delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_DELETE, + pubkey, + // Key by the target coordinate so cross-kind d-tag tombstones + // occupy distinct rows (F2c). + d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: persona-tombstone: {e}"); + } +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 9403c51ca5..faa9507989 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -410,792 +410,5 @@ pub fn persona_snapshot_with_agent_config_fallback( ..base } } - #[cfg(test)] -mod tests { - use super::*; - - fn sample_persona() -> PersonaRecord { - PersonaRecord { - id: "test-persona".to_string(), - display_name: "Test Persona".to_string(), - avatar_url: Some("https://example.com/avatar.png".to_string()), - system_prompt: "You are a test assistant.".to_string(), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: Some("test-slug".to_string()), - env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - } - } - - #[test] - fn monotonic_created_at_bumps_past_head() { - // No head: uses now (floor 0). - let now = nostr::Timestamp::now().as_secs() as i64; - let none = monotonic_created_at(None).as_secs() as i64; - assert!(none >= now, "no-head write must be >= now"); - - // Head in the FUTURE (same-second or clock-skewed): must bump to head+1, - // never reuse now (which would be <= head and lose the NIP-33 tiebreak). - let future_head = now + 1000; - let bumped = monotonic_created_at(Some(future_head)).as_secs() as i64; - assert_eq!( - bumped, - future_head + 1, - "must supersede a future head by +1" - ); - - // Head in the PAST: now already exceeds it, so now wins. - let past = monotonic_created_at(Some(now - 1000)).as_secs() as i64; - assert!(past >= now, "past head must not drag created_at backward"); - } - - #[test] - fn d_tag_uses_slug_when_available() { - let record = sample_persona(); - assert_eq!(persona_d_tag(&record), "test-slug"); - } - - #[test] - fn d_tag_falls_back_to_id() { - let mut record = sample_persona(); - record.source_team_persona_slug = None; - assert_eq!(persona_d_tag(&record), "test-persona"); - } - - /// Mirror of the relay slug grammar (`ingest.rs:923` `^[a-z0-9][a-z0-9_-]{0,63}$`) - /// so the normalization tests assert what the relay actually enforces. - fn passes_relay_slug_grammar(d: &str) -> bool { - let bytes = d.as_bytes(); - !d.is_empty() - && d.len() <= 64 - && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) - && bytes[1..] - .iter() - .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') - } - - #[test] - fn d_tag_normalizes_pack_slug_to_relay_grammar() { - // The cited failing cases: mixed-case and leading-underscore pack slugs - // that the relay rejects un-normalized → pending forever. - for (raw, expected) in [ - ("CodeReviewer", "codereviewer"), - ("_ops", "a_ops"), - ("Code-Reviewer", "code-reviewer"), - ("UPPER_snake", "upper_snake"), - ("-leading-dash", "a-leading-dash"), - ] { - let mut record = sample_persona(); - record.source_team_persona_slug = Some(raw.to_string()); - let d = persona_d_tag(&record); - assert_eq!(d, expected, "normalization of {raw:?}"); - assert!( - passes_relay_slug_grammar(&d), - "normalized {raw:?} -> {d:?} still fails the relay grammar" - ); - } - } - - #[test] - fn d_tag_already_valid_slug_is_unchanged() { - // In-app personas use a lowercase-hex UUID id — already valid, must pass - // through untouched (no spurious coordinate change on existing data). - let mut record = sample_persona(); - record.source_team_persona_slug = None; - record.id = "11111111-2222-3333-4444-555555555555".to_string(); - let d = persona_d_tag(&record); - assert_eq!(d, "11111111-2222-3333-4444-555555555555"); - assert!(passes_relay_slug_grammar(&d)); - } - - #[test] - fn build_persona_event_produces_correct_kind() { - let record = sample_persona(); - let builder = build_persona_event(&record).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); - } - - #[test] - fn round_trip_serialization() { - let record = sample_persona(); - let builder = build_persona_event(&record).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - - let restored = persona_from_event(&event).unwrap(); - assert_eq!(restored.id, "test-slug"); - assert_eq!(restored.display_name, "Test Persona"); - assert_eq!( - restored.avatar_url, - Some("https://example.com/avatar.png".to_string()) - ); - assert_eq!(restored.system_prompt, "You are a test assistant."); - assert_eq!(restored.runtime, Some("goose".to_string())); - assert_eq!(restored.model, Some("claude-opus-4".to_string())); - assert_eq!(restored.provider, Some("anthropic".to_string())); - assert_eq!(restored.name_pool, vec!["Alpha", "Beta"]); - // env_vars are not included in public persona events (secrets travel - // via NIP-44-encrypted engrams only). - assert!(restored.env_vars.is_empty()); - assert_eq!( - restored.source_team_persona_slug, - Some("test-slug".to_string()) - ); - assert!(!restored.is_builtin); - assert!(restored.is_active); - } - - /// NIP-AP reference vector (Event 1, `docs/nips/NIP-AP.md:195-207`): the - /// serialized content bytes MUST match the spec exactly, byte-for-byte. - /// serde emits fields in declaration order, so this pins the content - /// encoding — and therefore the NIP-01 event id — for cross-implementation - /// interop. The field order is `display_name, system_prompt, avatar_url, - /// runtime, model, provider, name_pool`. - #[test] - fn content_matches_nip_ap_vector() { - // Exact body from NIP-AP.md Event 1 (no trailing whitespace, no BOM). - const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; - - let content = PersonaEventContent { - display_name: "Test Agent".to_string(), - system_prompt: Some("You are a test assistant.".to_string()), - avatar_url: Some("https://example.com/avatar.png".to_string()), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - }; - assert_eq!( - serde_json::to_string(&content).unwrap(), - VECTOR, - "serialized content drifted from the NIP-AP Event 1 vector" - ); - - // Hash invariance across the unified-model widening: REAL pre-revision - // content bytes (fixture string, not a round-trip through the new - // struct) must parse and re-serialize byte-identically, so - // persona_content_hash — the drift-badge basis — is unchanged on - // upgrade. A bare Option serializing "system_prompt":null would flip - // every persona's hash fleet-wide. - let parsed: PersonaEventContent = serde_json::from_str(VECTOR).unwrap(); - assert_eq!( - serde_json::to_string(&parsed).unwrap(), - VECTOR, - "pre-revision content bytes must survive a parse/serialize round-trip unchanged" - ); - assert_eq!( - persona_content_hash(&parsed), - { - use sha2::{Digest, Sha256}; - hex::encode(Sha256::digest(VECTOR.as_bytes())) - }, - "persona_content_hash of pre-revision bytes must equal the direct digest" - ); - - // Hash stability, adversarial shapes: the empty prompt and the - // minimal old-writer body (display_name + system_prompt only) are the - // two easiest regressions if the projection or skip attributes ever - // change. - const EMPTY_PROMPT: &str = r#"{"display_name":"X","system_prompt":""}"#; - let parsed: PersonaEventContent = serde_json::from_str(EMPTY_PROMPT).unwrap(); - assert_eq!(serde_json::to_string(&parsed).unwrap(), EMPTY_PROMPT); - const MINIMAL: &str = r#"{"display_name":"Minimal","system_prompt":"Hello."}"#; - let parsed: PersonaEventContent = serde_json::from_str(MINIMAL).unwrap(); - assert_eq!(serde_json::to_string(&parsed).unwrap(), MINIMAL); - - // An event built from this content carries the byte-exact vector as its - // signed content, so a second implementer following the spec computes - // the same NIP-01 id. - let record = PersonaRecord { - id: "test-agent".to_string(), - display_name: "Test Agent".to_string(), - avatar_url: Some("https://example.com/avatar.png".to_string()), - system_prompt: "You are a test assistant.".to_string(), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - }; - let event = build_persona_event(&record) - .unwrap() - .sign_with_keys(&nostr::Keys::generate()) - .unwrap(); - assert_eq!(event.content, VECTOR); - } - - #[test] - fn round_trip_minimal_persona() { - let record = PersonaRecord { - id: "minimal".to_string(), - display_name: "Minimal".to_string(), - avatar_url: None, - system_prompt: "Hello".to_string(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: true, - is_active: false, - source_team: Some("team-1".to_string()), - source_team_persona_slug: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - }; - - let builder = build_persona_event(&record).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - - let restored = persona_from_event(&event).unwrap(); - assert_eq!(restored.id, "minimal"); - assert_eq!(restored.display_name, "Minimal"); - assert_eq!(restored.avatar_url, None); - assert_eq!(restored.runtime, None); - assert_eq!(restored.model, None); - assert_eq!(restored.provider, None); - assert!(restored.name_pool.is_empty()); - assert!(restored.env_vars.is_empty()); - // Deserialized persona is always non-builtin and active - assert!(!restored.is_builtin); - assert!(restored.is_active); - } - - #[test] - fn build_persona_delete_has_single_a_tag_no_e_tag() { - const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - let builder = build_persona_delete("test-slug", OWNER).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - - assert_eq!(event.kind, Kind::Custom(5)); - - let a_tags: Vec<&[String]> = event - .tags - .iter() - .map(|t| t.as_slice()) - .filter(|v| v.first().map(String::as_str) == Some("a")) - .collect(); - assert_eq!(a_tags.len(), 1); - assert_eq!(a_tags[0][1], format!("{KIND_PERSONA}:{OWNER}:test-slug")); - - // An e-tag would route to the event-id deletion path and leave the - // replaceable coordinate live — the tombstone must carry none. - assert!(event - .tags - .iter() - .all(|t| t.as_slice().first().map(String::as_str) != Some("e"))); - } - - /// NIP-AP behavioral defaults are LIVE since B5 (create-path - /// unification): the wire fields are carried on PersonaRecord in wire - /// shape and re-emitted verbatim by the projection — a foreign - /// definition's behavioral values now survive a local - /// edit-and-republish cycle. This test replaces - /// `behavioral_defaults_are_staged_not_applied` (the staging lock), - /// whose deliberate removal was pinned in the B5 review gates. - #[test] - fn behavioral_defaults_survive_record_round_trip() { - const FOREIGN: &str = r#"{"display_name":"F","system_prompt":"p","respond_to":"anyone","respond_to_allowlist":["deadbeef"],"mcp_toolsets":"default","parallelism":4}"#; - let parsed: PersonaEventContent = serde_json::from_str(FOREIGN).unwrap(); - // Wire layer preserves the fields... - assert_eq!(parsed.respond_to.as_deref(), Some("anyone")); - assert_eq!(parsed.parallelism, Some(4)); - // ...and the record round-trip now carries them through. - let record = persona_from_event_content_for_test(parsed); - let reprojected = persona_event_content(&record); - assert_eq!(reprojected.respond_to.as_deref(), Some("anyone")); - assert_eq!(reprojected.respond_to_allowlist, vec!["deadbeef"]); - assert_eq!(reprojected.mcp_toolsets.as_deref(), Some("default")); - assert_eq!(reprojected.parallelism, Some(4)); - } - - /// B5 hash row 1: a quad-absent definition's content bytes — and - /// therefore `persona_content_hash` — are identical before and after - /// quad activation. Pre-activation the projection hardcoded `None`; - /// post-activation it copies the record's (absent) quad. Both serialize - /// to the same bytes via `skip_serializing_if`, so no drift badge flips - /// and no republish wave fires for quad-absent definitions. - #[test] - fn quad_absent_definition_hash_stable_across_activation() { - let record = PersonaRecord { - id: "quad-absent".to_string(), - display_name: "Test".to_string(), - avatar_url: None, - system_prompt: "Hello".to_string(), - runtime: Some("goose".to_string()), - model: Some("gpt-oss".to_string()), - provider: None, - name_pool: vec!["nib".to_string()], - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-01T00:00:00Z".to_string(), - }; - let live = persona_event_content(&record); - // The reserved-era projection: identical fields, quad hardcoded off. - let reserved_era = PersonaEventContent { - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - ..live.clone() - }; - assert_eq!( - serde_json::to_string(&live).unwrap(), - serde_json::to_string(&reserved_era).unwrap(), - "quad-absent projection must serialize byte-identically to the reserved era" - ); - assert_eq!( - persona_content_hash(&live), - persona_content_hash(&reserved_era) - ); - } - - /// Test-only bridge: build a PersonaRecord from parsed content the same - /// way `persona_from_event` maps fields, without needing a signed event. - fn persona_from_event_content_for_test(content: PersonaEventContent) -> PersonaRecord { - PersonaRecord { - id: "staged".to_string(), - display_name: content.display_name, - avatar_url: content.avatar_url, - system_prompt: content.system_prompt.unwrap_or_default(), - runtime: content.runtime, - model: content.model, - provider: content.provider, - name_pool: content.name_pool, - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: BTreeMap::new(), - respond_to: content.respond_to, - respond_to_allowlist: content.respond_to_allowlist, - mcp_toolsets: content.mcp_toolsets, - parallelism: content.parallelism, - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-01T00:00:00Z".to_string(), - } - } - - #[test] - fn persona_content_hash_is_deterministic() { - let content = PersonaEventContent { - display_name: "Test".to_string(), - avatar_url: None, - system_prompt: Some("Hello".to_string()), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - }; - let hash1 = persona_content_hash(&content); - let hash2 = persona_content_hash(&content); - assert_eq!(hash1, hash2); - assert_eq!(hash1.len(), 64); // SHA-256 hex - } - - #[test] - fn persona_content_hash_changes_on_edit() { - let content1 = PersonaEventContent { - display_name: "Test".to_string(), - avatar_url: None, - system_prompt: Some("Hello".to_string()), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - respond_to: None, - respond_to_allowlist: Vec::new(), - mcp_toolsets: None, - parallelism: None, - }; - let mut content2 = content1.clone(); - content2.system_prompt = Some("Goodbye".to_string()); - assert_ne!( - persona_content_hash(&content1), - persona_content_hash(&content2) - ); - } - - // ── persona_field_with_record_fallback ──────────────────────────────────── - - #[test] - fn field_fallback_persona_present_wins() { - assert_eq!( - persona_field_with_record_fallback(Some("persona-model"), Some("record-model")), - Some("persona-model".to_owned()), - ); - } - - #[test] - fn field_fallback_persona_blank_uses_record() { - assert_eq!( - persona_field_with_record_fallback(None, Some("record-model")), - Some("record-model".to_owned()), - ); - assert_eq!( - persona_field_with_record_fallback(Some(" "), Some("record-model")), - Some("record-model".to_owned()), - ); - } - - #[test] - fn field_fallback_both_blank_is_none() { - assert_eq!(persona_field_with_record_fallback(None, None), None); - assert_eq!(persona_field_with_record_fallback(Some(""), Some("")), None); - } - - #[test] - fn field_fallback_record_blank_is_none() { - assert_eq!( - persona_field_with_record_fallback(None, Some(" ")), - None, - "whitespace-only record value must also be treated as blank" - ); - } - - // ── persona_snapshot_with_agent_config_fallback ──────────────────────────── - - /// Helper: a persona with no model/provider configured. - fn blank_model_persona() -> PersonaRecord { - PersonaRecord { - model: None, - provider: None, - ..sample_persona() - } - } - - /// (a) Persona leaves model/provider blank, agent record has values → - /// record values preserved AND source_version still updated to current hash. - #[test] - fn fallback_preserves_record_values_when_persona_blank() { - let persona = blank_model_persona(); - let expected_version = persona_content_hash(&persona_event_content(&persona)); - - let snapshot = - persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4o"), Some("openai")); - - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "blank persona model must fall back to agent record value" - ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "blank persona provider must fall back to agent record value" - ); - assert_eq!( - snapshot.source_version, expected_version, - "source_version must still reflect current persona hash" - ); - } - - /// (b) Persona has model/provider set → persona wins over agent record. - #[test] - fn fallback_persona_wins_when_set() { - let persona = sample_persona(); // has model=Some("claude-opus-4"), provider=Some("anthropic") - - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // agent had a different model - Some("openai"), // agent had a different provider - ); - - assert_eq!( - snapshot.model.as_deref(), - Some("claude-opus-4"), - "persona model must win when persona has a value" - ); - assert_eq!( - snapshot.provider.as_deref(), - Some("anthropic"), - "persona provider must win when persona has a value" - ); - } - - /// (c) Both blank → snapshot keeps None; a genuinely unconfigured agent - /// stays unconfigured (no fabricated values). - #[test] - fn fallback_both_blank_stays_none() { - let persona = blank_model_persona(); - - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, None, // agent also has no model - None, // agent also has no provider - ); - - assert!( - snapshot.model.is_none(), - "neither persona nor agent has model — snapshot must be None" - ); - assert!( - snapshot.provider.is_none(), - "neither persona nor agent has provider — snapshot must be None" - ); - } - - /// Whitespace-only values on the persona are treated as blank; agent - /// fallback applies. - #[test] - fn fallback_treats_whitespace_only_persona_value_as_blank() { - let mut persona = sample_persona(); - persona.model = Some(" ".to_string()); - persona.provider = Some("\t".to_string()); - - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("claude-opus-4"), - Some("anthropic"), - ); - - assert_eq!( - snapshot.model.as_deref(), - Some("claude-opus-4"), - "whitespace-only persona model must be treated as blank" - ); - assert_eq!( - snapshot.provider.as_deref(), - Some("anthropic"), - "whitespace-only persona provider must be treated as blank" - ); - } - - /// Cross-field independence: persona sets model but not provider → model - /// comes from persona, provider falls back to the record. This is the - /// practically common case (model-only personas). - #[test] - fn fallback_persona_model_set_provider_blank_uses_record_provider() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.provider = None; // blank provider on persona - - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be overridden by persona) - Some("openai"), // record provider (should be preserved) - ); - - assert_eq!( - snapshot.model.as_deref(), - Some("claude-opus-4"), - "persona model must win when persona has a value" - ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "record provider must be used when persona provider is blank" - ); - } - - /// Inverse: persona sets provider but not model → provider comes from - /// persona, model falls back to the record. - #[test] - fn fallback_persona_provider_set_model_blank_uses_record_model() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.model = None; // blank model on persona - - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be preserved) - Some("openai"), // record provider (should be overridden by persona) - ); - - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "record model must be used when persona model is blank" - ); - assert_eq!( - snapshot.provider.as_deref(), - Some("anthropic"), - "persona provider must win when persona has a value" - ); - } - - // Gated off Windows for the same reason as `archive::real_relay`: - // `build_app_state()` pulls native DLLs unavailable in the Windows CI - // runner. This stub-relay test is hermetic (localhost axum) otherwise. - #[cfg(not(target_os = "windows"))] - mod flush_barrier { - use super::*; - use crate::app_state::build_app_state; - use crate::managed_agents::retention::{ - get_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }; - use nostr::JsonUtil; - - /// Stub relay: `POST /events` rejects kind:5 with HTTP 500, accepts - /// everything else. Returns the HTTP base URL. - async fn spawn_stub_relay() -> String { - use axum::{http::StatusCode, routing::post, Router}; - - let app = Router::new().route( - "/events", - post(|body: String| async move { - let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); - if event.get("kind").and_then(serde_json::Value::as_u64) == Some(5) { - return (StatusCode::INTERNAL_SERVER_ERROR, String::new()); - } - ( - StatusCode::OK, - serde_json::json!({ - "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), - "accepted": true, - "message": "" - }) - .to_string(), - ) - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind stub relay"); - let addr = listener.local_addr().expect("stub relay addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - format!("http://{addr}") - } - - fn retain_signed( - conn: &rusqlite::Connection, - keys: &nostr::Keys, - kind: u32, - retention_d_tag: &str, - builder: nostr::EventBuilder, - created_at: i64, - ) { - let event = builder.sign_with_keys(keys).expect("sign test event"); - retain_event( - conn, - &RetainedEvent { - kind, - pubkey: keys.public_key().to_hex(), - d_tag: retention_d_tag.to_string(), - content: event.content.to_string(), - created_at, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - .expect("retain test event"); - } - - /// The mid-sweep barrier: a tombstone the relay rejects must defer its - /// own replacement to the next sweep (still pending, not counted as - /// flushed) while unrelated rows in the same sweep publish normally. - /// Failing toward stay-deleted is the safe direction — the deferred - /// replacement can never be wiped by its own late tombstone. - #[tokio::test] - async fn failed_tombstone_defers_replacement_within_sweep() { - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - let dir = tempfile::tempdir().expect("tempdir"); - let db_path = dir.path().join("retention.db"); - - { - let conn = open_retention_db(&db_path).expect("open db"); - // Tombstone (publishes first, relay rejects it). - retain_signed( - &conn, - &keys, - 5, - &tombstone_retention_d_tag(KIND_PERSONA, "covered"), - build_persona_delete("covered", &pubkey).unwrap(), - 1000, - ); - // Its replacement at the same coordinate (must defer). - retain_signed( - &conn, - &keys, - KIND_PERSONA, - "covered", - EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") - .tags(vec![Tag::parse(["d", "covered"]).unwrap()]), - 2000, - ); - // Unrelated coordinate (must publish despite the barrier). - retain_signed( - &conn, - &keys, - KIND_PERSONA, - "unrelated", - EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") - .tags(vec![Tag::parse(["d", "unrelated"]).unwrap()]), - 1500, - ); - } - - let state = build_app_state(); - *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); - - let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); - assert_eq!(flushed, 1, "only the unrelated row publishes"); - - let conn = open_retention_db(&db_path).expect("reopen db"); - let row = |kind: u32, d_tag: &str| { - get_retained_event(&conn, kind, &pubkey, d_tag) - .unwrap() - .unwrap() - }; - assert!( - row(5, &tombstone_retention_d_tag(KIND_PERSONA, "covered")).pending_sync, - "failed tombstone stays pending" - ); - assert!( - row(KIND_PERSONA, "covered").pending_sync, - "deferred replacement stays pending" - ); - assert!( - !row(KIND_PERSONA, "unrelated").pending_sync, - "unrelated row marked synced" - ); - } - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs new file mode 100644 index 0000000000..87b37c3fb1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -0,0 +1,785 @@ +use super::*; + +fn sample_persona() -> PersonaRecord { + PersonaRecord { + id: "test-persona".to_string(), + display_name: "Test Persona".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "You are a test assistant.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: Some("test-slug".to_string()), + env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + } +} + +#[test] +fn monotonic_created_at_bumps_past_head() { + // No head: uses now (floor 0). + let now = nostr::Timestamp::now().as_secs() as i64; + let none = monotonic_created_at(None).as_secs() as i64; + assert!(none >= now, "no-head write must be >= now"); + + // Head in the FUTURE (same-second or clock-skewed): must bump to head+1, + // never reuse now (which would be <= head and lose the NIP-33 tiebreak). + let future_head = now + 1000; + let bumped = monotonic_created_at(Some(future_head)).as_secs() as i64; + assert_eq!( + bumped, + future_head + 1, + "must supersede a future head by +1" + ); + + // Head in the PAST: now already exceeds it, so now wins. + let past = monotonic_created_at(Some(now - 1000)).as_secs() as i64; + assert!(past >= now, "past head must not drag created_at backward"); +} + +#[test] +fn d_tag_uses_slug_when_available() { + let record = sample_persona(); + assert_eq!(persona_d_tag(&record), "test-slug"); +} + +#[test] +fn d_tag_falls_back_to_id() { + let mut record = sample_persona(); + record.source_team_persona_slug = None; + assert_eq!(persona_d_tag(&record), "test-persona"); +} + +/// Mirror of the relay slug grammar (`ingest.rs:923` `^[a-z0-9][a-z0-9_-]{0,63}$`) +/// so the normalization tests assert what the relay actually enforces. +fn passes_relay_slug_grammar(d: &str) -> bool { + let bytes = d.as_bytes(); + !d.is_empty() + && d.len() <= 64 + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes[1..] + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') +} + +#[test] +fn d_tag_normalizes_pack_slug_to_relay_grammar() { + // The cited failing cases: mixed-case and leading-underscore pack slugs + // that the relay rejects un-normalized → pending forever. + for (raw, expected) in [ + ("CodeReviewer", "codereviewer"), + ("_ops", "a_ops"), + ("Code-Reviewer", "code-reviewer"), + ("UPPER_snake", "upper_snake"), + ("-leading-dash", "a-leading-dash"), + ] { + let mut record = sample_persona(); + record.source_team_persona_slug = Some(raw.to_string()); + let d = persona_d_tag(&record); + assert_eq!(d, expected, "normalization of {raw:?}"); + assert!( + passes_relay_slug_grammar(&d), + "normalized {raw:?} -> {d:?} still fails the relay grammar" + ); + } +} + +#[test] +fn d_tag_already_valid_slug_is_unchanged() { + // In-app personas use a lowercase-hex UUID id — already valid, must pass + // through untouched (no spurious coordinate change on existing data). + let mut record = sample_persona(); + record.source_team_persona_slug = None; + record.id = "11111111-2222-3333-4444-555555555555".to_string(); + let d = persona_d_tag(&record); + assert_eq!(d, "11111111-2222-3333-4444-555555555555"); + assert!(passes_relay_slug_grammar(&d)); +} + +#[test] +fn build_persona_event_produces_correct_kind() { + let record = sample_persona(); + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); +} + +#[test] +fn round_trip_serialization() { + let record = sample_persona(); + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + let restored = persona_from_event(&event).unwrap(); + assert_eq!(restored.id, "test-slug"); + assert_eq!(restored.display_name, "Test Persona"); + assert_eq!( + restored.avatar_url, + Some("https://example.com/avatar.png".to_string()) + ); + assert_eq!(restored.system_prompt, "You are a test assistant."); + assert_eq!(restored.runtime, Some("goose".to_string())); + assert_eq!(restored.model, Some("claude-opus-4".to_string())); + assert_eq!(restored.provider, Some("anthropic".to_string())); + assert_eq!(restored.name_pool, vec!["Alpha", "Beta"]); + // env_vars are not included in public persona events (secrets travel + // via NIP-44-encrypted engrams only). + assert!(restored.env_vars.is_empty()); + assert_eq!( + restored.source_team_persona_slug, + Some("test-slug".to_string()) + ); + assert!(!restored.is_builtin); + assert!(restored.is_active); +} + +/// NIP-AP reference vector (Event 1, `docs/nips/NIP-AP.md:195-207`): the +/// serialized content bytes MUST match the spec exactly, byte-for-byte. +/// serde emits fields in declaration order, so this pins the content +/// encoding — and therefore the NIP-01 event id — for cross-implementation +/// interop. The field order is `display_name, system_prompt, avatar_url, +/// runtime, model, provider, name_pool`. +#[test] +fn content_matches_nip_ap_vector() { + // Exact body from NIP-AP.md Event 1 (no trailing whitespace, no BOM). + const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; + + let content = PersonaEventContent { + display_name: "Test Agent".to_string(), + system_prompt: Some("You are a test assistant.".to_string()), + avatar_url: Some("https://example.com/avatar.png".to_string()), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + }; + assert_eq!( + serde_json::to_string(&content).unwrap(), + VECTOR, + "serialized content drifted from the NIP-AP Event 1 vector" + ); + + // Hash invariance across the unified-model widening: REAL pre-revision + // content bytes (fixture string, not a round-trip through the new + // struct) must parse and re-serialize byte-identically, so + // persona_content_hash — the drift-badge basis — is unchanged on + // upgrade. A bare Option serializing "system_prompt":null would flip + // every persona's hash fleet-wide. + let parsed: PersonaEventContent = serde_json::from_str(VECTOR).unwrap(); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + VECTOR, + "pre-revision content bytes must survive a parse/serialize round-trip unchanged" + ); + assert_eq!( + persona_content_hash(&parsed), + { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(VECTOR.as_bytes())) + }, + "persona_content_hash of pre-revision bytes must equal the direct digest" + ); + + // Hash stability, adversarial shapes: the empty prompt and the + // minimal old-writer body (display_name + system_prompt only) are the + // two easiest regressions if the projection or skip attributes ever + // change. + const EMPTY_PROMPT: &str = r#"{"display_name":"X","system_prompt":""}"#; + let parsed: PersonaEventContent = serde_json::from_str(EMPTY_PROMPT).unwrap(); + assert_eq!(serde_json::to_string(&parsed).unwrap(), EMPTY_PROMPT); + const MINIMAL: &str = r#"{"display_name":"Minimal","system_prompt":"Hello."}"#; + let parsed: PersonaEventContent = serde_json::from_str(MINIMAL).unwrap(); + assert_eq!(serde_json::to_string(&parsed).unwrap(), MINIMAL); + + // An event built from this content carries the byte-exact vector as its + // signed content, so a second implementer following the spec computes + // the same NIP-01 id. + let record = PersonaRecord { + id: "test-agent".to_string(), + display_name: "Test Agent".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "You are a test assistant.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + let event = build_persona_event(&record) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + assert_eq!(event.content, VECTOR); +} + +#[test] +fn round_trip_minimal_persona() { + let record = PersonaRecord { + id: "minimal".to_string(), + display_name: "Minimal".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: true, + is_active: false, + source_team: Some("team-1".to_string()), + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + let restored = persona_from_event(&event).unwrap(); + assert_eq!(restored.id, "minimal"); + assert_eq!(restored.display_name, "Minimal"); + assert_eq!(restored.avatar_url, None); + assert_eq!(restored.runtime, None); + assert_eq!(restored.model, None); + assert_eq!(restored.provider, None); + assert!(restored.name_pool.is_empty()); + assert!(restored.env_vars.is_empty()); + // Deserialized persona is always non-builtin and active + assert!(!restored.is_builtin); + assert!(restored.is_active); +} + +#[test] +fn build_persona_delete_has_single_a_tag_no_e_tag() { + const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let builder = build_persona_delete("test-slug", OWNER).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + assert_eq!(event.kind, Kind::Custom(5)); + + let a_tags: Vec<&[String]> = event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|v| v.first().map(String::as_str) == Some("a")) + .collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("{KIND_PERSONA}:{OWNER}:test-slug")); + + // An e-tag would route to the event-id deletion path and leave the + // replaceable coordinate live — the tombstone must carry none. + assert!(event + .tags + .iter() + .all(|t| t.as_slice().first().map(String::as_str) != Some("e"))); +} + +/// NIP-AP behavioral defaults are LIVE since B5 (create-path +/// unification): the wire fields are carried on PersonaRecord in wire +/// shape and re-emitted verbatim by the projection — a foreign +/// definition's behavioral values now survive a local +/// edit-and-republish cycle. This test replaces +/// `behavioral_defaults_are_staged_not_applied` (the staging lock), +/// whose deliberate removal was pinned in the B5 review gates. +#[test] +fn behavioral_defaults_survive_record_round_trip() { + const FOREIGN: &str = r#"{"display_name":"F","system_prompt":"p","respond_to":"anyone","respond_to_allowlist":["deadbeef"],"mcp_toolsets":"default","parallelism":4}"#; + let parsed: PersonaEventContent = serde_json::from_str(FOREIGN).unwrap(); + // Wire layer preserves the fields... + assert_eq!(parsed.respond_to.as_deref(), Some("anyone")); + assert_eq!(parsed.parallelism, Some(4)); + // ...and the record round-trip now carries them through. + let record = persona_from_event_content_for_test(parsed); + let reprojected = persona_event_content(&record); + assert_eq!(reprojected.respond_to.as_deref(), Some("anyone")); + assert_eq!(reprojected.respond_to_allowlist, vec!["deadbeef"]); + assert_eq!(reprojected.mcp_toolsets.as_deref(), Some("default")); + assert_eq!(reprojected.parallelism, Some(4)); +} + +/// B5 hash row 1: a quad-absent definition's content bytes — and +/// therefore `persona_content_hash` — are identical before and after +/// quad activation. Pre-activation the projection hardcoded `None`; +/// post-activation it copies the record's (absent) quad. Both serialize +/// to the same bytes via `skip_serializing_if`, so no drift badge flips +/// and no republish wave fires for quad-absent definitions. +#[test] +fn quad_absent_definition_hash_stable_across_activation() { + let record = PersonaRecord { + id: "quad-absent".to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: Some("goose".to_string()), + model: Some("gpt-oss".to_string()), + provider: None, + name_pool: vec!["nib".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + let live = persona_event_content(&record); + // The reserved-era projection: identical fields, quad hardcoded off. + let reserved_era = PersonaEventContent { + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + ..live.clone() + }; + assert_eq!( + serde_json::to_string(&live).unwrap(), + serde_json::to_string(&reserved_era).unwrap(), + "quad-absent projection must serialize byte-identically to the reserved era" + ); + assert_eq!( + persona_content_hash(&live), + persona_content_hash(&reserved_era) + ); +} + +/// Test-only bridge: build a PersonaRecord from parsed content the same +/// way `persona_from_event` maps fields, without needing a signed event. +fn persona_from_event_content_for_test(content: PersonaEventContent) -> PersonaRecord { + PersonaRecord { + id: "staged".to_string(), + display_name: content.display_name, + avatar_url: content.avatar_url, + system_prompt: content.system_prompt.unwrap_or_default(), + runtime: content.runtime, + model: content.model, + provider: content.provider, + name_pool: content.name_pool, + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: content.respond_to, + respond_to_allowlist: content.respond_to_allowlist, + mcp_toolsets: content.mcp_toolsets, + parallelism: content.parallelism, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +#[test] +fn persona_content_hash_is_deterministic() { + let content = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + }; + let hash1 = persona_content_hash(&content); + let hash2 = persona_content_hash(&content); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex +} + +#[test] +fn persona_content_hash_changes_on_edit() { + let content1 = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, + }; + let mut content2 = content1.clone(); + content2.system_prompt = Some("Goodbye".to_string()); + assert_ne!( + persona_content_hash(&content1), + persona_content_hash(&content2) + ); +} + +// ── persona_field_with_record_fallback ──────────────────────────────────── + +#[test] +fn field_fallback_persona_present_wins() { + assert_eq!( + persona_field_with_record_fallback(Some("persona-model"), Some("record-model")), + Some("persona-model".to_owned()), + ); +} + +#[test] +fn field_fallback_persona_blank_uses_record() { + assert_eq!( + persona_field_with_record_fallback(None, Some("record-model")), + Some("record-model".to_owned()), + ); + assert_eq!( + persona_field_with_record_fallback(Some(" "), Some("record-model")), + Some("record-model".to_owned()), + ); +} + +#[test] +fn field_fallback_both_blank_is_none() { + assert_eq!(persona_field_with_record_fallback(None, None), None); + assert_eq!(persona_field_with_record_fallback(Some(""), Some("")), None); +} + +#[test] +fn field_fallback_record_blank_is_none() { + assert_eq!( + persona_field_with_record_fallback(None, Some(" ")), + None, + "whitespace-only record value must also be treated as blank" + ); +} + +// ── persona_snapshot_with_agent_config_fallback ──────────────────────────── + +/// Helper: a persona with no model/provider configured. +fn blank_model_persona() -> PersonaRecord { + PersonaRecord { + model: None, + provider: None, + ..sample_persona() + } +} + +/// (a) Persona leaves model/provider blank, agent record has values → +/// record values preserved AND source_version still updated to current hash. +#[test] +fn fallback_preserves_record_values_when_persona_blank() { + let persona = blank_model_persona(); + let expected_version = persona_content_hash(&persona_event_content(&persona)); + + let snapshot = + persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4o"), Some("openai")); + + assert_eq!( + snapshot.model.as_deref(), + Some("gpt-4o"), + "blank persona model must fall back to agent record value" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("openai"), + "blank persona provider must fall back to agent record value" + ); + assert_eq!( + snapshot.source_version, expected_version, + "source_version must still reflect current persona hash" + ); +} + +/// (b) Persona has model/provider set → persona wins over agent record. +#[test] +fn fallback_persona_wins_when_set() { + let persona = sample_persona(); // has model=Some("claude-opus-4"), provider=Some("anthropic") + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("gpt-4o"), // agent had a different model + Some("openai"), // agent had a different provider + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("claude-opus-4"), + "persona model must win when persona has a value" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("anthropic"), + "persona provider must win when persona has a value" + ); +} + +/// (c) Both blank → snapshot keeps None; a genuinely unconfigured agent +/// stays unconfigured (no fabricated values). +#[test] +fn fallback_both_blank_stays_none() { + let persona = blank_model_persona(); + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, None, // agent also has no model + None, // agent also has no provider + ); + + assert!( + snapshot.model.is_none(), + "neither persona nor agent has model — snapshot must be None" + ); + assert!( + snapshot.provider.is_none(), + "neither persona nor agent has provider — snapshot must be None" + ); +} + +/// Whitespace-only values on the persona are treated as blank; agent +/// fallback applies. +#[test] +fn fallback_treats_whitespace_only_persona_value_as_blank() { + let mut persona = sample_persona(); + persona.model = Some(" ".to_string()); + persona.provider = Some("\t".to_string()); + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("claude-opus-4"), + Some("anthropic"), + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("claude-opus-4"), + "whitespace-only persona model must be treated as blank" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("anthropic"), + "whitespace-only persona provider must be treated as blank" + ); +} + +/// Cross-field independence: persona sets model but not provider → model +/// comes from persona, provider falls back to the record. This is the +/// practically common case (model-only personas). +#[test] +fn fallback_persona_model_set_provider_blank_uses_record_provider() { + let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") + persona.provider = None; // blank provider on persona + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("gpt-4o"), // record model (should be overridden by persona) + Some("openai"), // record provider (should be preserved) + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("claude-opus-4"), + "persona model must win when persona has a value" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("openai"), + "record provider must be used when persona provider is blank" + ); +} + +/// Inverse: persona sets provider but not model → provider comes from +/// persona, model falls back to the record. +#[test] +fn fallback_persona_provider_set_model_blank_uses_record_model() { + let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") + persona.model = None; // blank model on persona + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("gpt-4o"), // record model (should be preserved) + Some("openai"), // record provider (should be overridden by persona) + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("gpt-4o"), + "record model must be used when persona model is blank" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("anthropic"), + "persona provider must win when persona has a value" + ); +} + +// Gated off Windows for the same reason as `archive::real_relay`: +// `build_app_state()` pulls native DLLs unavailable in the Windows CI +// runner. This stub-relay test is hermetic (localhost axum) otherwise. +#[cfg(not(target_os = "windows"))] +mod flush_barrier { + use super::*; + use crate::app_state::build_app_state; + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }; + use nostr::JsonUtil; + + /// Stub relay: `POST /events` rejects kind:5 with HTTP 500, accepts + /// everything else. Returns the HTTP base URL. + async fn spawn_stub_relay() -> String { + use axum::{http::StatusCode, routing::post, Router}; + + let app = Router::new().route( + "/events", + post(|body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + if event.get("kind").and_then(serde_json::Value::as_u64) == Some(5) { + return (StatusCode::INTERNAL_SERVER_ERROR, String::new()); + } + ( + StatusCode::OK, + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": true, + "message": "" + }) + .to_string(), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stub relay"); + let addr = listener.local_addr().expect("stub relay addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") + } + + fn retain_signed( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + kind: u32, + retention_d_tag: &str, + builder: nostr::EventBuilder, + created_at: i64, + ) { + let event = builder.sign_with_keys(keys).expect("sign test event"); + retain_event( + conn, + &RetainedEvent { + kind, + pubkey: keys.public_key().to_hex(), + d_tag: retention_d_tag.to_string(), + content: event.content.to_string(), + created_at, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .expect("retain test event"); + } + + /// The mid-sweep barrier: a tombstone the relay rejects must defer its + /// own replacement to the next sweep (still pending, not counted as + /// flushed) while unrelated rows in the same sweep publish normally. + /// Failing toward stay-deleted is the safe direction — the deferred + /// replacement can never be wiped by its own late tombstone. + #[tokio::test] + async fn failed_tombstone_defers_replacement_within_sweep() { + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + { + let conn = open_retention_db(&db_path).expect("open db"); + // Tombstone (publishes first, relay rejects it). + retain_signed( + &conn, + &keys, + 5, + &tombstone_retention_d_tag(KIND_PERSONA, "covered"), + build_persona_delete("covered", &pubkey).unwrap(), + 1000, + ); + // Its replacement at the same coordinate (must defer). + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "covered", + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") + .tags(vec![Tag::parse(["d", "covered"]).unwrap()]), + 2000, + ); + // Unrelated coordinate (must publish despite the barrier). + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "unrelated", + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") + .tags(vec![Tag::parse(["d", "unrelated"]).unwrap()]), + 1500, + ); + } + + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!(flushed, 1, "only the unrelated row publishes"); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = |kind: u32, d_tag: &str| { + get_retained_event(&conn, kind, &pubkey, d_tag) + .unwrap() + .unwrap() + }; + assert!( + row(5, &tombstone_retention_d_tag(KIND_PERSONA, "covered")).pending_sync, + "failed tombstone stays pending" + ); + assert!( + row(KIND_PERSONA, "covered").pending_sync, + "deferred replacement stays pending" + ); + assert!( + !row(KIND_PERSONA, "unrelated").pending_sync, + "unrelated row marked synced" + ); + } +} From 75cd8d771b4f6fd7fe953702367b51b9887b8432 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 16:52:04 -0600 Subject: [PATCH 08/14] feat(desktop): collapse the New-agent menu to the unified definition flow (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the "Custom agent" entry from the New-agent card menu — the definition-family create dialog (with its start-after-create toggle) is now the one create path. The app-wide create shortcut (AppShell command palette / sidebar, via openCreateAgentEvent) routes to the definition flow instead of the standalone-instance dialog, and the menu's import entry is renamed "Import agent file" per the B5 copy pin. The instance branch of AgentDialog and CreateAgentDialog itself remain until the provider/mesh re-home lands (next commits); e2e specs that drive the removed menu entry migrate with the deletion. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/src/features/agents/ui/AgentsView.tsx | 10 +++++----- .../src/features/agents/ui/UnifiedAgentsSection.tsx | 12 +----------- desktop/src/features/agents/ui/personaLibraryCopy.ts | 1 - 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index c9ea9da75c..0e64eb1d6f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -58,13 +58,16 @@ export function AgentsView() { teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only shortcut subscription; openUnifiedCreate only calls stable setState-backed callbacks React.useEffect(() => { + // The app-wide "create agent" shortcut routes to the unified definition + // flow (B5): one create path, with the start-after-create toggle on. if (consumePendingOpenCreateAgent()) { - setCreateDialogMode("instance"); + openUnifiedCreate("definition"); } return subscribeOpenCreateAgent(() => { - setCreateDialogMode("instance"); + openUnifiedCreate("definition"); }); }, []); @@ -92,9 +95,6 @@ export function AgentsView() { onBulkStopRunning={() => { void agents.handleBulkStopRunning(); }} - onCreateAgent={() => { - openUnifiedCreate("instance"); - }} onOpenAgentProfile={(pubkey, options) => { openProfilePanel?.(pubkey, options); }} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index c035bfbbd0..28c46f8d8e 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -20,7 +20,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; @@ -41,7 +40,6 @@ type UnifiedAgentsSectionProps = { startingPersonaIds: ReadonlySet; onBulkRemoveStopped: () => void; onBulkStopRunning: () => void; - onCreateAgent: () => void; onOpenAgentProfile: ( pubkey: string, options?: ProfilePanelOpenOptions, @@ -81,7 +79,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { startingPersonaIds, onBulkRemoveStopped, onBulkStopRunning, - onCreateAgent, onOpenAgentProfile, onOpenPersonaProfile, onStartAgent, @@ -210,7 +207,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasPending={isPersonasPending} openFilePicker={openFilePicker} onChooseCatalog={onChooseCatalog} - onCreateAgent={onCreateAgent} onCreatePersona={onCreatePersona} /> @@ -503,14 +499,12 @@ function NewAgentCard({ isPersonasPending, openFilePicker, onChooseCatalog, - onCreateAgent, onCreatePersona, }: { canChooseCatalog: boolean; isPersonasPending: boolean; openFilePicker: () => void; onChooseCatalog: () => void; - onCreateAgent: () => void; onCreatePersona: () => void; }) { return ( @@ -540,12 +534,8 @@ function NewAgentCard({ Choose from catalog ) : null} - - - Custom agent - - Import persona file + Import agent file diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 293e05bff6..030f1a6c27 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -4,7 +4,6 @@ export const personaLibraryCopy = { "The agents you have chosen for this app. Use them to create teams and launch agents.", chooseFromCatalog: "Choose from catalog", createNew: "New agent", - customAgent: "Custom agent", import: "Import", emptyTitle: "No agents yet", emptyDescription: From 60555c41bd5d985aad561de407dc7c9af8310d6c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 17:34:05 -0600 Subject: [PATCH 09/14] feat(desktop): re-home provider and mesh backends into the definition-create flow (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The definition-create dialog gains a "Where to run" section (local default / discovered backend provider / relay mesh), replacing the runtime surface that died with the standalone-instance dialog. The section is rendered only while the start-after-create toggle is ON: "where to run" is instance state, and with the toggle off no instance exists. - buildInstanceInputForDefinition gains an optional BackendIntent arg. Absent intent produces byte-identical output to before (deep-equality fixture row); the 3 existing call sites pass nothing and are untouched. The provider branch mirrors the legacy provider-mode create (no local commands, no model/provider, startOnAppLaunch forced false, spawnAfterCreate true); the mesh branch carries the preset patch as instance-override state (harnessOverride true, relayMesh modelRef, startOnAppLaunch forced false). - whereToRunIntent.ts owns the draft-to-intent resolution. resolveBackendIntent discards any selection when the start toggle is off, so a stale provider/mesh pick can never silently ride a definition-only create. canSubmitWhereToRun carries the legacy submit gates: provider blocks until probe + required config; mesh blocks until a concrete serve target. - The mesh preflight moved into mintDefinitionWithPreflight, which runs meshPrepareRelayMeshClient BEFORE the definition mint in usePersonaActions.handleSubmit — a dead mesh target aborts the whole create and never orphans a definition. Preflight and mint live in one function so callers cannot reorder them; a lib test row asserts a failed preflight never mints. - Honest-copy note: the legacy mesh override warning compared the preset to the dialog's own field state. The definition path never overwrites the definition — only the minted instance carries mesh values — so RelayMeshAgentSection gets an empty `current` and the warning stays silent by construction (disclosed for the PR body). Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../lib/instanceInputForDefinition.test.mjs | 152 +++++++++++++ .../agents/lib/instanceInputForDefinition.ts | 103 ++++++++- .../agents/ui/AgentDefinitionDialog.tsx | 9 + .../src/features/agents/ui/AgentDialog.tsx | 22 ++ .../features/agents/ui/WhereToRunSection.tsx | 211 ++++++++++++++++++ .../features/agents/ui/usePersonaActions.ts | 29 ++- .../agents/ui/whereToRunIntent.test.mjs | 134 +++++++++++ .../features/agents/ui/whereToRunIntent.ts | 98 ++++++++ 8 files changed, 748 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/agents/ui/WhereToRunSection.tsx create mode 100644 desktop/src/features/agents/ui/whereToRunIntent.test.mjs create mode 100644 desktop/src/features/agents/ui/whereToRunIntent.ts diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index 01cb04fe54..dfd287ad23 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { availableRuntimesForStart, buildInstanceInputForDefinition, + mintDefinitionWithPreflight, resolveStartRuntimeForDefinition, } from "./instanceInputForDefinition.ts"; @@ -127,6 +128,157 @@ test("mapping carries the runtime and definition fields", async () => { assert.deepEqual(input.backend, { type: "local" }); }); +test("no backend intent is byte-identical to the pre-intent mapping", async () => { + // The 3 pre-B5 call sites (useManagedAgentActions, usePersonaActions, + // UserProfilePanel) pass no intent; their output must not move. + const input = await buildInstanceInputForDefinition(persona(), gooseRuntime); + assert.deepEqual(input, { + name: "Test Agent", + personaId: "p-1", + systemPrompt: "prompt", + avatarUrl: "https://example.com/a.png", + acpCommand: "buzz-acp", + agentCommand: "goose-cmd", + agentArgs: ["--acp"], + mcpCommand: "goose-mcp", + harnessOverride: true, + model: undefined, + spawnAfterCreate: true, + startOnAppLaunch: true, + backend: { type: "local" }, + }); +}); + +test("provider intent forces startOnAppLaunch off and omits local commands", async () => { + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + { type: "provider", id: "blox", config: { region: "us" } }, + ); + assert.deepEqual(input.backend, { + type: "provider", + id: "blox", + config: { region: "us" }, + }); + assert.equal(input.startOnAppLaunch, false, "remote agents never auto-start"); + assert.equal(input.spawnAfterCreate, true); + assert.equal(input.harnessOverride, false); + // Provider agents spawn no local ACP — the legacy provider branch omitted + // all local commands and model/provider, and so does the intent path. + for (const key of [ + "acpCommand", + "agentCommand", + "agentArgs", + "mcpCommand", + "model", + "envVars", + "relayMesh", + ]) { + assert.equal(key in input, false, `provider intent must omit ${key}`); + } + assert.equal(input.personaId, "p-1", "definition link is kept"); + assert.equal(input.systemPrompt, "prompt"); +}); + +test("mesh intent applies the preset patch as instance-override state", async () => { + const patch = { + acpCommand: "buzz-acp", + agentCommand: "buzz-agent", + agentArgs: ["acp"], + mcpCommand: "", + model: "mesh/model:Q4", + envVars: { OPENAI_BASE_URL: "http://127.0.0.1:9337/v1" }, + }; + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + { + type: "mesh", + modelId: "mesh/model:Q4", + target: { endpointAddr: "10.0.0.1:9337", modelId: "mesh/model:Q4" }, + patch, + }, + ); + assert.equal(input.agentCommand, "buzz-agent"); + assert.deepEqual(input.agentArgs, ["acp"]); + assert.equal(input.model, "mesh/model:Q4"); + assert.deepEqual(input.envVars, patch.envVars); + assert.deepEqual(input.relayMesh, { modelRef: "mesh/model:Q4" }); + assert.equal( + input.harnessOverride, + true, + "preset commands deliberately override the definition runtime", + ); + assert.equal( + input.startOnAppLaunch, + false, + "mesh agents need a fresh serve target; never auto-restore", + ); + assert.deepEqual(input.backend, { type: "local" }); + assert.equal(input.personaId, "p-1", "definition link is kept"); + // The patch must be copied, not aliased — a caller mutating its patch + // after the fact must not reach into the built input. + patch.agentArgs.push("mutated"); + patch.envVars.INJECTED = "x"; + assert.deepEqual(input.agentArgs, ["acp"]); + assert.equal("INJECTED" in input.envVars, false); +}); + +test("preflight runs only for mesh intent, before the mint, and a failure never mints", async () => { + const calls = []; + const prepare = async (modelId, target) => { + calls.push(["prepare", modelId, target]); + }; + const mint = async () => { + calls.push(["mint"]); + return "definition"; + }; + + // Local (no intent) and provider intents mint immediately, no preflight. + assert.equal( + await mintDefinitionWithPreflight(undefined, prepare, mint), + "definition", + ); + await mintDefinitionWithPreflight(null, prepare, mint); + await mintDefinitionWithPreflight( + { type: "provider", id: "blox", config: {} }, + prepare, + mint, + ); + assert.deepEqual( + calls, + [["mint"], ["mint"], ["mint"]], + "non-mesh intents must not preflight", + ); + + // Mesh intent preflights with the selected target BEFORE the mint. + calls.length = 0; + const target = { endpointAddr: "10.0.0.1:9337", modelId: "m" }; + await mintDefinitionWithPreflight( + { type: "mesh", modelId: "m", target, patch: {} }, + prepare, + mint, + ); + assert.deepEqual(calls, [["prepare", "m", target], ["mint"]]); + + // A preflight rejection propagates and the mint NEVER runs — a dead mesh + // target must not orphan a definition the user didn't ask for. + calls.length = 0; + await assert.rejects( + mintDefinitionWithPreflight( + { type: "mesh", modelId: "m", target, patch: {} }, + async () => { + throw new Error("target unreachable"); + }, + mint, + ), + /target unreachable/, + ); + assert.deepEqual(calls, [], "a failed preflight must not mint anything"); +}); + test("row 1: refuses when the configured runtime is not available", () => { assert.throws( () => diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index f4f20284fd..3faa5318ff 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -4,6 +4,8 @@ import type { AgentPersona, CreateManagedAgentInput, } from "@/shared/api/types"; +import type { MeshAgentPresetPatch } from "@/features/mesh-compute/applyMeshAgentPreset"; +import type { MeshServeTarget } from "@/shared/api/tauriMesh"; import { resolvePersonaRuntime, type ResolvePersonaRuntimeResult, @@ -61,6 +63,56 @@ export function resolveStartRuntimeForDefinition( return { runtime, warnings }; } +/** + * Where the started instance should run when the user picked something other + * than plain local in the definition-create flow (B5). Absent intent = + * today's local mapping, byte-identical. + * + * - `provider`: remote backend. Mirrors the legacy provider-mode create: + * no local ACP/agent/MCP commands are spawned, so none are set; + * `startOnAppLaunch` is forced false (remote agents don't auto-start with + * the desktop) and `spawnAfterCreate` true. + * - `mesh`: relay-mesh compute. The preset patch carries the instance + * commands/env the legacy dialog fanned into its field state; env lands in + * record env_vars (the instance-override layer — the dial pointer is + * per-instance runtime state, never definition env). `harnessOverride` + * is true because the preset commands deliberately override the + * definition's runtime preference. + */ +export type BackendIntent = + | { + type: "provider"; + id: string; + config: Record; + } + | { + type: "mesh"; + modelId: string; + /** The selected serve target — consumed by the pre-mint preflight. */ + target: MeshServeTarget; + patch: MeshAgentPresetPatch; + }; + +/** + * Run the relay-mesh client preflight for a mesh intent, then mint the + * definition. The preflight runs BEFORE the mint — definition included — so + * a dead mesh target aborts the whole create instead of orphaning a + * definition the user didn't ask for; a rejection propagates and `mint` is + * never invoked. Non-mesh intents mint immediately: the preflight never runs + * for local or provider creates. Preflight and mint live in one function so + * a caller cannot reorder them. + */ +export async function mintDefinitionWithPreflight( + backendIntent: BackendIntent | null | undefined, + prepare: (modelId: string, target: MeshServeTarget) => Promise, + mint: () => Promise, +): Promise { + if (backendIntent?.type === "mesh") { + await prepare(backendIntent.modelId, backendIntent.target); + } + return mint(); +} + /** * The single definition→instance mapping (Phase 1B.3.5 rows 2–4). Every * surface that creates a running instance from a definition builds its @@ -76,12 +128,15 @@ export function resolveStartRuntimeForDefinition( * - envVars are never seeded from the definition: record.env_vars is * agent overrides only and spawn merges the live definition env * underneath. Seeding would manufacture pseudo-overrides that mask - * later definition edits made before the first spawn. + * later definition edits made before the first spawn. (Mesh preset env is + * the deliberate exception: it is instance-override state, not + * definition env.) */ export async function buildInstanceInputForDefinition( persona: AgentPersona, runtime: AcpRuntime, upload?: UploadMediaBytes, + backendIntent?: BackendIntent, ): Promise { const avatarUrl = await resolveManagedAgentAvatarUrl( persona.avatarUrl, @@ -89,16 +144,54 @@ export async function buildInstanceInputForDefinition( runtime.avatarUrl, ); - return { + const base = { name: persona.displayName, + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl, + }; + + if (backendIntent?.type === "provider") { + return { + ...base, + harnessOverride: false, + spawnAfterCreate: true, + startOnAppLaunch: false, + backend: { + type: "provider", + id: backendIntent.id, + config: backendIntent.config, + }, + }; + } + + if (backendIntent?.type === "mesh") { + const { patch } = backendIntent; + return { + ...base, + acpCommand: patch.acpCommand, + agentCommand: patch.agentCommand, + agentArgs: [...patch.agentArgs], + mcpCommand: patch.mcpCommand, + harnessOverride: true, + model: patch.model, + envVars: { ...patch.envVars }, + relayMesh: { modelRef: backendIntent.modelId }, + spawnAfterCreate: true, + // Relay-mesh agents need a freshly selected serve target to start; + // do not auto-restore them later with only the saved model/env. + startOnAppLaunch: false, + backend: { type: "local" }, + }; + } + + return { + ...base, acpCommand: "buzz-acp", agentCommand: runtime.command, agentArgs: runtime.defaultArgs, mcpCommand: runtime.mcpCommand ?? "", - personaId: persona.id, harnessOverride: !persona.runtime || persona.runtime === runtime.id, - systemPrompt: persona.systemPrompt, - avatarUrl, model: persona.model ?? undefined, spawnAfterCreate: true, startOnAppLaunch: true, diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index c5d4c28829..5888ffb26a 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -107,6 +107,10 @@ type AgentDefinitionDialogProps = { * import button owns that slot (`canImportPersonaUpdate`). */ createFooterSlot?: React.ReactNode; + /** Rendered below the form fields in create mode only ("Where to run"). */ + createRunSection?: React.ReactNode; + /** Extra create-mode submit gate (e.g. incomplete provider config). */ + createSubmitBlocked?: boolean; }; const ADVANCED_FIELDS_MOTION_TRANSITION = { @@ -129,6 +133,8 @@ export function AgentDefinitionDialog({ onSubmit, onImportUpdateFile, createFooterSlot, + createRunSection, + createSubmitBlocked = false, }: AgentDefinitionDialogProps) { const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); @@ -435,6 +441,7 @@ export function AgentDefinitionDialog({ canSubmitPersonaDialog({ displayName, isPending }) && (!isCreateMode || runtime.trim().length > 0) && (!isCreateMode || selectedRuntimeIsAvailable) && + (!isCreateMode || !createSubmitBlocked) && (!isExplicitModelRequired || model.trim().length > 0) && !isAvatarUploadPending; const { @@ -893,6 +900,8 @@ export function AgentDefinitionDialog({ ) : null} + {isCreateMode ? createRunSection : null} +
- {createDialogMode ? ( + {isCreateDialogOpen ? ( { - agents.setLogAgentPubkey(result.agent.pubkey); - agents.setCreatedAgent(result); - }} + mode="definition" onOpenChange={(open) => { if (!open) { - setCreateDialogMode(null); + setIsCreateDialogOpen(false); } }} onSubmitDefinition={personas.handleSubmit} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx deleted file mode 100644 index 2a638ef78d..0000000000 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ /dev/null @@ -1,877 +0,0 @@ -import { AlertTriangle, ChevronDown } from "lucide-react"; -import * as React from "react"; - -import { - useAcpRuntimesQuery, - useAvailableAcpRuntimes, - useBackendProvidersQuery, - useBakedBuildEnvKeysQuery, - useCreateManagedAgentMutation, - useManagedAgentPrereqsQuery, - useRuntimeFileConfigQuery, -} from "@/features/agents/hooks"; -import { probeBackendProvider } from "@/shared/api/tauri"; -import type { - BackendProviderProbeResult, - CreateManagedAgentInput, - CreateManagedAgentResponse, - RespondToMode, -} from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { - CreateAgentBasicsFields, - CreateAgentOptionToggles, - CreateAgentRuntimeField, - CreateAgentRuntimeFields, -} from "./CreateAgentDialogSections"; -import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; -import { - coerceConfigValues, - ProviderConfigFields, -} from "./ProviderConfigFields"; -import { CreateAgentRespondToField } from "./RespondToField"; -import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection"; -import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh"; -import type { MeshServeTarget } from "@/shared/api/tauriMesh"; -import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; -import { - getBakedSatisfiedEnvKeys, - requiredCredentialEnvKeys, - runtimeSupportsLlmProviderSelection, - shouldClearKnownModelForSelectionScope, -} from "./personaDialogPickers"; -import { - selectionOnProviderDropdownChange, - selectionOnRuntimeChange, - type RuntimeModelProviderSelection, -} from "./runtimeModelProviderSelection"; -import { - AgentModelField, - AgentProviderField, -} from "./personaProviderModelFields"; -import { usePersonaModelDiscovery } from "./usePersonaModelDiscovery"; - -export function CreateAgentDialog({ - open, - onCreated, - onOpenChange, -}: { - open: boolean; - onCreated: (result: CreateManagedAgentResponse) => void; - onOpenChange: (open: boolean) => void; -}) { - const createMutation = useCreateManagedAgentMutation(); - const providersQuery = useAvailableAcpRuntimes({ enabled: open }); - const allProvidersQuery = useAcpRuntimesQuery({ enabled: open }); - const backendProvidersQuery = useBackendProvidersQuery({ enabled: open }); - const { lastRuntimeId, setLastRuntime } = useLastRuntime(); - const [acpCommand, setAcpCommand] = React.useState("buzz-acp"); - const [agentCommand, setAgentCommand] = React.useState("buzz-agent"); - const [agentArgs, setAgentArgs] = React.useState("acp"); - const [mcpCommand, setMcpCommand] = React.useState(""); - const [mcpToolsets, setMcpToolsets] = React.useState(""); - const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand, { - enabled: open, - }); - const [name, setName] = React.useState(""); - const [relayUrl, setRelayUrl] = React.useState(""); - const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true); - const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true); - const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320"); - const [parallelism, setParallelism] = React.useState("24"); - const [systemPrompt, setSystemPrompt] = React.useState(""); - const [envVars, setEnvVars] = React.useState({}); - const [selectedRuntimeId, setSelectedRuntimeId] = - React.useState("custom"); - const [hasSyncedProviderSelection, setHasSyncedProviderSelection] = - React.useState(false); - const [showAdvanced, setShowAdvanced] = React.useState(false); - const [respondTo, setRespondTo] = React.useState("owner-only"); - const [respondToAllowlist, setRespondToAllowlist] = React.useState( - [], - ); - - const [runOn, setRunOn] = React.useState<"local" | string>("local"); - const [providerConfig, setProviderConfig] = React.useState< - Record - >({}); - const [probedProvider, setProbedProvider] = - React.useState(null); - const [probeError, setProbeError] = React.useState(null); - - // Local-mode LLM provider and model — structured state for the credential - // gate and live discovery. Only rendered when the runtime supports provider - // selection (buzz-agent / goose). - const [provider, setProvider] = React.useState(""); - const [model, setModel] = React.useState(""); - const [isCustomProviderEditing, setIsCustomProviderEditing] = - React.useState(false); - const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); - - // When `useMesh` is on, the agent runs buzz-agent against a member's - // shared compute. The ACP runtime + backend selectors are hidden; runtime - // fields are driven by `mesh_agent_preset(meshModelId)` and the submit - // input carries `model: meshModelId`. - const [useMesh, setUseMesh] = React.useState(false); - const [meshModelId, setMeshModelId] = React.useState(""); - const [meshTarget, setMeshTarget] = React.useState( - null, - ); - const [meshClientError, setMeshClientError] = React.useState( - null, - ); - // True while the relay-mesh client preflight (connect-request + dial) runs - // inside handleSubmit. That await can take tens of seconds and happens - // before createMutation fires, so isPending alone leaves the button live. - const [meshPreparing, setMeshPreparing] = React.useState(false); - - const runtimes = providersQuery.data ?? []; - const allProviders = allProvidersQuery.data ?? []; - const unavailableCount = allProviders.filter( - (p) => p.availability !== "available", - ).length; - const backendProviders = backendProvidersQuery.data ?? []; - const prereqs = prereqsQuery.data ?? null; - const selectedRuntime = React.useMemo( - () => runtimes.find((runtime) => runtime.id === selectedRuntimeId) ?? null, - [runtimes, selectedRuntimeId], - ); - const selectedBackendProvider = React.useMemo( - () => backendProviders.find((p) => p.id === runOn) ?? null, - [backendProviders, runOn], - ); - // Relay mesh always runs in local mode (buzz-agent + OpenAI-compat env); - // when on, it suppresses the backend "Run on" branch even if a stale - // `runOn` value remains. The relay-mesh path is its own thing. - const isProviderMode = !useMesh && runOn !== "local"; - - const isSpawnSupported = - prereqs?.acp.available === true && prereqs?.mcp.available === true; - const spawnToggleDisabled = - prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); - const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; - - // Local mode: provider/model field visibility and live discovery. - // Create has no inherit checkbox — selectedRuntimeId IS the prospective runtime. - const llmProviderFieldVisible = - !isProviderMode && - !useMesh && - runtimeSupportsLlmProviderSelection(selectedRuntimeId); - const providerForDiscovery = llmProviderFieldVisible ? provider : ""; - - const { - discoveredModelOptions, - modelDiscoveryLoading, - modelDiscoveryStatus, - } = usePersonaModelDiscovery({ - envVars, - isCustomProviderEditing, - modelFieldVisible: llmProviderFieldVisible, - open, - provider: providerForDiscovery, - selectedRuntime: selectedRuntime ?? undefined, - }); - - // Full required credential key list for EnvVarsEditor amber locked rows — - // includes already-satisfied keys, not just missing ones. - const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( - selectedRuntimeId, - { enabled: open }, - ); - const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); - - // All required keys for the selected runtime + provider. - const allRequiredEnvKeys = React.useMemo( - () => - requiredCredentialEnvKeys( - selectedRuntimeId, - runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", - ), - [selectedRuntimeId, provider], - ); - - // Keys covered by the baked build env (Block-internal builds only) — silenced, - // produce no amber row or info row. - const bakedSatisfiedEnvKeys = React.useMemo( - () => getBakedSatisfiedEnvKeys(allRequiredEnvKeys, envVars, bakedEnvKeys), - [allRequiredEnvKeys, envVars, bakedEnvKeys], - ); - - // Credential keys satisfied by the runtime file config (e.g. goose config.yaml). - // These are shown as "Set in goose config" rows rather than amber required rows. - const fileSatisfiedEnvKeys = React.useMemo(() => { - if (!runtimeFileConfig) return [] as string[]; - return allRequiredEnvKeys.filter( - (key) => - (envVars[key] ?? "").length === 0 && - !bakedSatisfiedEnvKeys.includes(key) && - runtimeFileConfig.satisfiedEnvKeys.includes(key), - ); - }, [runtimeFileConfig, allRequiredEnvKeys, envVars, bakedSatisfiedEnvKeys]); - - const requiredEnvKeys = React.useMemo( - () => - allRequiredEnvKeys.filter( - (key) => - !bakedSatisfiedEnvKeys.includes(key) && - !fileSatisfiedEnvKeys.includes(key), - ), - [allRequiredEnvKeys, bakedSatisfiedEnvKeys, fileSatisfiedEnvKeys], - ); - - // Clear model when provider scope changes, mirroring AgentInstanceEditDialog. - React.useEffect(() => { - if ( - !open || - isCustomModelEditing || - !shouldClearKnownModelForSelectionScope({ - model, - provider: providerForDiscovery, - runtime: selectedRuntimeId, - }) - ) { - return; - } - setModel(""); - setIsCustomModelEditing(false); - }, [ - isCustomModelEditing, - model, - open, - providerForDiscovery, - selectedRuntimeId, - ]); - - React.useEffect(() => { - if (hasSyncedProviderSelection || providersQuery.isLoading) { - return; - } - - // Prefer last-used runtime from localStorage - const remembered = lastRuntimeId - ? runtimes.find((runtime) => runtime.id === lastRuntimeId) - : null; - if (remembered) { - setSelectedRuntimeId(remembered.id); - setAgentCommand(remembered.command); - setAgentArgs(remembered.defaultArgs.join(",")); - setMcpCommand(remembered.mcpCommand ?? ""); - } else { - const matchingProvider = - runtimes.find((runtime) => runtime.command === agentCommand) ?? null; - if (matchingProvider) { - setSelectedRuntimeId(matchingProvider.id); - } - } - setHasSyncedProviderSelection(true); - }, [ - agentCommand, - hasSyncedProviderSelection, - lastRuntimeId, - runtimes, - providersQuery.isLoading, - ]); - - React.useEffect(() => { - if ( - !prereqs || - (prereqs.acp.available && prereqs.mcp.available) || - !spawnAfterCreate - ) { - return; - } - - setSpawnAfterCreate(false); - }, [prereqs, spawnAfterCreate]); - - React.useEffect(() => { - if ( - providersQuery.error instanceof Error || - prereqsQuery.error instanceof Error - ) { - setShowAdvanced(true); - } - }, [prereqsQuery.error, providersQuery.error]); - - // Probe the backend provider when runOn changes to a non-local value - React.useEffect(() => { - if (!isProviderMode || !selectedBackendProvider) { - setProbedProvider(null); - setProbeError(null); - return; - } - - let cancelled = false; - setProbeError(null); - setProbedProvider(null); - - probeBackendProvider(selectedBackendProvider.binaryPath) - .then((result) => { - if (!cancelled) { - setProbedProvider(result); - // Initialize config from schema defaults so unchanged defaults - // are included in the submit payload (not silently dropped). - if (result.config_schema) { - const props = - (result.config_schema as Record)?.properties ?? - {}; - const defaults: Record = {}; - for (const [key, prop] of Object.entries(props) as [ - string, - Record, - ][]) { - if (prop.default != null) { - defaults[key] = String(prop.default); - } - } - setProviderConfig(defaults); - } - } - }) - .catch((err: unknown) => { - if (!cancelled) { - setProbeError(err instanceof Error ? err.message : String(err)); - } - }); - - return () => { - cancelled = true; - }; - }, [isProviderMode, selectedBackendProvider]); - - function reset() { - setName(""); - setRelayUrl(""); - setSpawnAfterCreate(true); - setStartOnAppLaunch(true); - setAcpCommand("buzz-acp"); - setAgentCommand("buzz-agent"); - setAgentArgs("acp"); - setMcpCommand(""); - setMcpToolsets(""); - setTurnTimeoutSeconds("320"); - setParallelism("24"); - setSystemPrompt(""); - setEnvVars({}); - setSelectedRuntimeId("custom"); - setHasSyncedProviderSelection(false); - setShowAdvanced(false); - setRunOn("local"); - setProviderConfig({}); - setProbedProvider(null); - setProbeError(null); - setProvider(""); - setModel(""); - setIsCustomProviderEditing(false); - setIsCustomModelEditing(false); - setUseMesh(false); - setMeshModelId(""); - setMeshClientError(null); - setRespondTo("owner-only"); - setRespondToAllowlist([]); - createMutation.reset(); - } - - function handleOpenChange(next: boolean) { - if (!next) { - reset(); - } - - onOpenChange(next); - } - - const selection: RuntimeModelProviderSelection = { - provider, - model, - isCustomProviderEditing, - isCustomModelEditing, - envVars, - }; - - function applySelection(next: RuntimeModelProviderSelection) { - setProvider(next.provider); - setModel(next.model); - setIsCustomProviderEditing(next.isCustomProviderEditing); - setIsCustomModelEditing(next.isCustomModelEditing); - setEnvVars(next.envVars); - } - - function handleProviderChange(nextProviderId: string) { - const previousRuntimeId = selectedRuntimeId; - setSelectedRuntimeId(nextProviderId); - - if (nextProviderId === "custom") { - setShowAdvanced(true); - } else { - const runtime = runtimes.find( - (candidate) => candidate.id === nextProviderId, - ); - if (runtime) { - setLastRuntime(nextProviderId); - setAgentCommand(runtime.command); - setAgentArgs(runtime.defaultArgs.join(",")); - setMcpCommand(runtime.mcpCommand ?? ""); - } - } - - applySelection( - selectionOnRuntimeChange(selection, { - previousRuntime: previousRuntimeId, - nextRuntime: nextProviderId, - nextRuntimeCanChooseProvider: - runtimeSupportsLlmProviderSelection(nextProviderId), - lockedRuntimeReset: "provider-only", - }), - ); - } - - function handleRunOnChange(value: string) { - setRunOn(value); - setProviderConfig({}); - setProbedProvider(null); - setProbeError(null); - } - - // Provider dropdown handler for local-mode provider field — mirrors Edit. - function handleProviderDropdownChange(nextValue: string) { - applySelection( - selectionOnProviderDropdownChange(selection, { - runtime: selectedRuntimeId, - nextValue, - clearModelWhenApiKeyMissing: false, - }), - ); - } - - // Check provider config required fields are filled. - const providerConfigComplete = React.useMemo(() => { - if (!isProviderMode || !probedProvider?.config_schema) return true; - const schema = probedProvider.config_schema as Record; - const required: string[] = (schema?.required as string[] | undefined) ?? []; - return required.every( - (key) => (providerConfig[key] ?? "").trim().length > 0, - ); - }, [isProviderMode, probedProvider, providerConfig]); - - // Allowlist mode requires at least one entry, mirroring the harness's own - // validation. If we let it through empty, the agent crash-loops at startup - // with a config error. - const respondToValid = - respondTo !== "allowlist" || respondToAllowlist.length > 0; - - const canSubmit = - name.trim().length > 0 && - !isDiscoveryPending && - !( - !isProviderMode && - spawnAfterCreate && - prereqs !== null && - !isSpawnSupported - ) && - // Block submission until probe succeeds in provider mode — required - // fields and config schema are only known after a successful probe. - !(isProviderMode && !probedProvider) && - providerConfigComplete && - // Relay-mesh mode requires a concrete serve target, not just a model name. - !(useMesh && (meshModelId.trim().length === 0 || meshTarget == null)) && - respondToValid && - !meshPreparing && - !createMutation.isPending; - - async function handleSubmit() { - setMeshClientError(null); - try { - if (useMesh) { - try { - if (!meshTarget) { - setMeshClientError( - "Select a relay mesh serve target before creating the agent.", - ); - return; - } - setMeshPreparing(true); - try { - await meshPrepareRelayMeshClient(meshModelId.trim(), meshTarget); - } finally { - setMeshPreparing(false); - } - } catch (err) { - setMeshClientError(err instanceof Error ? err.message : String(err)); - return; - } - } - - // Only send the allowlist when the mode is actually "allowlist". - // Other modes ignore it server-side, but keeping the wire clean makes - // the agent record easier to inspect. - const respondToFields = { - respondTo, - respondToAllowlist: - respondTo === "allowlist" ? respondToAllowlist : undefined, - } as const; - - const input: CreateManagedAgentInput = isProviderMode - ? { - name: name.trim(), - relayUrl: relayUrl.trim() || undefined, - turnTimeoutSeconds: - Number.parseInt(turnTimeoutSeconds, 10) > 0 - ? Number.parseInt(turnTimeoutSeconds, 10) - : undefined, - parallelism: - Number.parseInt(parallelism, 10) > 0 - ? Number.parseInt(parallelism, 10) - : undefined, - systemPrompt: systemPrompt.trim() || undefined, - envVars, - spawnAfterCreate: true, - startOnAppLaunch: false, // Remote agents don't auto-start with the desktop - backend: { - type: "provider", - id: runOn, - config: coerceConfigValues( - providerConfig, - probedProvider?.config_schema, - ), - }, - ...respondToFields, - } - : { - name: name.trim(), - relayUrl: relayUrl.trim() || undefined, - acpCommand: acpCommand.trim() || undefined, - agentCommand: agentCommand.trim() || undefined, - agentArgs: agentArgs - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0), - mcpCommand: mcpCommand.trim() || undefined, - mcpToolsets: mcpToolsets.trim() || undefined, - turnTimeoutSeconds: - Number.parseInt(turnTimeoutSeconds, 10) > 0 - ? Number.parseInt(turnTimeoutSeconds, 10) - : undefined, - parallelism: - Number.parseInt(parallelism, 10) > 0 - ? Number.parseInt(parallelism, 10) - : undefined, - systemPrompt: systemPrompt.trim() || undefined, - envVars, - model: useMesh - ? meshModelId.trim() || undefined - : model.trim() || undefined, - provider: !useMesh ? provider.trim() || undefined : undefined, - relayMesh: useMesh ? { modelRef: meshModelId.trim() } : undefined, - spawnAfterCreate, - // Relay-mesh agents need a freshly selected serve target to start; - // do not auto-restore them later with only the saved model/env. - startOnAppLaunch: useMesh ? false : startOnAppLaunch, - backend: { type: "local" }, - ...respondToFields, - }; - - const created = await createMutation.mutateAsync(input); - handleOpenChange(false); - onCreated(created); - } catch { - // React Query stores the error; keep the dialog open and render it inline. - } - } - - return ( - - -
- - Create agent - - This creates a local agent identity, syncs its display name when - possible, and can spawn `buzz-acp` immediately. - - - -
- - - v.trim()) - .filter((v) => v.length > 0), - mcpCommand, - model: meshModelId || null, - envVars, - }} - modelId={meshModelId} - targetEndpointAddr={meshTarget?.endpointAddr ?? ""} - onModelIdChange={(nextId, patch) => { - setMeshModelId(nextId); - if (patch == null) return; - // Fan out the preset into the existing setters so the rest - // of the dialog (and the submit branch) see normal local-mode - // values — relay-mesh is a curated local agent. - setAcpCommand(patch.acpCommand); - setAgentCommand(patch.agentCommand); - setAgentArgs(patch.agentArgs.join(",")); - setMcpCommand(patch.mcpCommand); - setEnvVars(patch.envVars); - }} - onTargetChange={setMeshTarget} - onUseMeshChange={(next) => { - setUseMesh(next); - if (!next) { - // Clearing the toggle: drop the model selection so the - // submit guard doesn't fire on a stale value. The runtime - // fields keep whatever the user had — they can re-pick - // ACP runtime or stay with the preset values, their call. - setMeshModelId(""); - setMeshTarget(null); - } - }} - useMesh={useMesh} - /> - - {/* Run on selector — only shown when backend providers are discovered */} - {!useMesh && backendProviders.length > 0 ? ( -
- - -
- ) : null} - - {/* Provider mode: trust warning + config fields */} - {isProviderMode && selectedBackendProvider ? ( -
-
- -

- This provider at{" "} - - {selectedBackendProvider.binaryPath} - {" "} - will receive your agent's private key. Only use - providers from trusted sources. -

-
- - {probeError ? ( -

- Could not probe provider: {probeError} -

- ) : null} - - {probedProvider?.config_schema ? ( - - ) : null} -
- ) : null} - - {/* Local mode: show the ACP runtime selector */} - {!isProviderMode && !useMesh ? ( - - ) : null} - - {/* Local mode: structured provider + model fields for credential - gate and live discovery. Only when the runtime supports it. */} - {llmProviderFieldVisible ? ( - - ) : null} - {llmProviderFieldVisible ? ( - - ) : null} - - { - setStartOnAppLaunch((current) => !current); - }} - onToggleSpawnAfterCreate={() => { - if (!spawnToggleDisabled) { - setSpawnAfterCreate((current) => !current); - } - }} - prereqs={prereqs} - startOnAppLaunch={isProviderMode ? false : startOnAppLaunch} - startOnAppLaunchDisabled={isProviderMode} - spawnAfterCreate={isProviderMode ? true : spawnAfterCreate} - spawnToggleDisabled={isProviderMode || spawnToggleDisabled} - /> - - - -
- - - {showAdvanced ? ( -
-
- - -

- Local Buzz binary checks and ACP runtime discovery now - live in Settings > Doctor. -

- - {providersQuery.error instanceof Error ? ( -

- {providersQuery.error.message} -

- ) : null} - - {prereqsQuery.error instanceof Error ? ( -

- {prereqsQuery.error.message} -

- ) : null} -
-
- ) : null} -
- - - - {meshClientError ? ( -

- Mesh client failed to start: {meshClientError} -

- ) : null} - - {createMutation.error instanceof Error ? ( -

- {createMutation.error.message} -

- ) : null} -
- -
- - -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx deleted file mode 100644 index f8be21c2ef..0000000000 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import type { AcpRuntime, ManagedAgentPrereqs } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { Input } from "@/shared/ui/input"; -import { Textarea } from "@/shared/ui/textarea"; -import { describeResolvedCommand } from "./agentUi"; - -export function CreateAgentBasicsFields({ - name, - onNameChange, -}: { - name: string; - onNameChange: (value: string) => void; -}) { - return ( -
- - onNameChange(event.target.value)} - placeholder="Support bot" - spellCheck={false} - value={name} - /> -

- Used as the local label and synced to the agent profile display name - when the relay accepts the create-time auth. -

-
- ); -} - -export function CreateAgentRuntimeField({ - runtimes, - runtimesLoading, - selectedRuntime, - selectedRuntimeId, - unavailableCount, - onRuntimeChange, -}: { - runtimes: AcpRuntime[]; - runtimesLoading: boolean; - selectedRuntime: AcpRuntime | null; - selectedRuntimeId: string; - unavailableCount: number; - onRuntimeChange: (value: string) => void; -}) { - return ( -
- - - {selectedRuntime ? ( -

- Detected via{" "} - - {describeResolvedCommand( - selectedRuntime.command, - selectedRuntime.binaryPath, - )} - -

- ) : runtimesLoading ? ( -

- Looking for installed ACP runtimes... -

- ) : ( -

- No known ACP runtime was detected. You can still enter a custom - command in Advanced setup. -

- )} - {unavailableCount > 0 ? ( -

- {unavailableCount} additional{" "} - {unavailableCount === 1 ? "runtime" : "runtimes"} available to - install. Visit Settings > Doctor to set{" "} - {unavailableCount === 1 ? "it" : "them"} up. -

- ) : null} -
- ); -} - -export function CreateAgentRuntimeFields({ - acpCommand, - agentArgs, - agentCommand, - mcpCommand, - mcpToolsets, - parallelism, - relayUrl, - selectedRuntimeId, - systemPrompt, - turnTimeoutSeconds, - onAcpCommandChange, - onAgentArgsChange, - onAgentCommandChange, - onMcpCommandChange, - onMcpToolsetsChange, - onParallelismChange, - onRelayUrlChange, - onSystemPromptChange, - onTurnTimeoutChange, -}: { - acpCommand: string; - agentArgs: string; - agentCommand: string; - mcpCommand: string; - mcpToolsets: string; - parallelism: string; - relayUrl: string; - selectedRuntimeId: string; - systemPrompt: string; - turnTimeoutSeconds: string; - onAcpCommandChange: (value: string) => void; - onAgentArgsChange: (value: string) => void; - onAgentCommandChange: (value: string) => void; - onMcpCommandChange: (value: string) => void; - onMcpToolsetsChange: (value: string) => void; - onParallelismChange: (value: string) => void; - onRelayUrlChange: (value: string) => void; - onSystemPromptChange: (value: string) => void; - onTurnTimeoutChange: (value: string) => void; -}) { - return ( - <> -
- {/* Relay URL pins the agent to a specific relay; blank falls back to - the active workspace relay. Shown for all agents. */} -
- - onRelayUrlChange(event.target.value)} - placeholder="Leave blank to use the workspace relay" - value={relayUrl} - /> -

- WebSocket URL of the relay this agent connects to. Leave blank to - use the active workspace relay. -

-
- -
- - onAcpCommandChange(event.target.value)} - value={acpCommand} - /> -

- The buzz-acp binary path or alias used to launch the ACP harness - process. -

-
-
- - {selectedRuntimeId === "custom" ? ( -
- - onAgentCommandChange(event.target.value)} - value={agentCommand} - /> -

- Full path or shell command for the agent binary when no known ACP - runtime was detected. -

-
- ) : null} - -
-
- - onAgentArgsChange(event.target.value)} - placeholder="Comma-separated" - value={agentArgs} - /> -

- buzz-acp splits args on commas, matching the testing guide. -

-
- -
- - onMcpCommandChange(event.target.value)} - value={mcpCommand} - /> -

- Optional. Only needed for agents that use a custom MCP server (e.g. - buzz-agent with buzz-dev-mcp). Leave blank for most agents. -

-
- -
- - onTurnTimeoutChange(event.target.value)} - placeholder="300" - value={turnTimeoutSeconds} - /> -

- Seconds before an agent turn is cancelled. Defaults to 300. -

-
- -
- - onParallelismChange(event.target.value)} - placeholder="1" - step="1" - type="number" - value={parallelism} - /> -

- Number of ACP worker subprocesses. buzz-acp allows 1-32. -

-
-
- -
- - onMcpToolsetsChange(event.target.value)} - placeholder="default,canvas,forums,dms,media" - value={mcpToolsets} - /> -

- Comma-separated list of toolsets to expose via BUZZ_TOOLSETS. - Available: default, channel_admin, dms, canvas, workflow_admin, - identity, forums, social, media. Leave blank for default toolsets - (default, canvas, forums, dms, media). -

-
- -
- -