From ce88863d7816ce0f931b1ab03e525be50cafe360 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 2 Jul 2026 22:39:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20agents=20own=20their=20config?= =?UTF-8?q?=20=E2=80=94=20team=20agent=20members,=20flattening=20migration?= =?UTF-8?q?,=20built-in=20templates=20(Tauri)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend half of removing personas from the UI. User-created agents no longer have a backing persona record; persona records are quarantined to team packs, which genuinely need them for directory install/sync and writeback. - types.rs, team_events.rs: add agent_pubkeys to TeamRecord, the create/update team inputs, and the kind:30176 payload. The field is additive — events published by older clients parse with empty agent membership (round-trip and tolerance tests included) - commands/teams.rs: normalize and validate agent pubkeys on team create/update; team export resolves agent members into the interchange member shape alongside persona members; pack install/sync materialize pack personas as stopped agents so they appear in the grid immediately - commands/personas/mod.rs: inbound kind:30176 reconcile applies agent_pubkeys from remote events - managed_agents/flatten.rs (new): idempotent boot migration — materialize a stopped agent for every active persona without one, rewrite non-pack team persona members to agent pubkeys, pin persona config onto linked agents and clear persona_id, then deactivate the flattened non-pack personas (records stay on disk/relay for rollback). flatten_tests.rs covers materialization, team rewrite, pack quarantine, and re-run idempotency - managed_agents/personas.rs: stop seeding built-in personas as records; the same data is now a static AgentTemplate list, and merge_personas only demotes stale is_builtin flags - commands/agent_templates.rs (new): list_agent_templates and export_agent_to_json — agent-record export to the .persona.json interchange format (env vars excluded, matching persona export) - commands/agents.rs: create_managed_agent accepts an explicit provider so templates and duplicates can pin the LLM provider at create time - lib.rs: run the flatten migration post-identity, before event sync, so rewritten teams/personas are what get reconciled and published; register the new template commands - team_repair.rs, personas/writeback.rs, inbound_tests.rs, personas/tests.rs: fixtures and merge-semantics tests updated for the new fields and the removal of built-in seeding; the shared test UUID constant avoids a digit run the secret scanner reads as a card number Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src-tauri/src/commands/agent_templates.rs | 73 ++++ desktop/src-tauri/src/commands/agents.rs | 9 +- desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/personas/inbound_tests.rs | 4 +- .../src-tauri/src/commands/personas/mod.rs | 2 + .../src/commands/personas/writeback.rs | 3 +- desktop/src-tauri/src/commands/teams.rs | 42 +- desktop/src-tauri/src/lib.rs | 25 ++ .../src-tauri/src/managed_agents/flatten.rs | 398 ++++++++++++++++++ .../src/managed_agents/flatten_tests.rs | 292 +++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/personas.rs | 108 ++--- .../src/managed_agents/personas/tests.rs | 139 ++---- .../src/managed_agents/team_events.rs | 19 + .../src/managed_agents/team_repair.rs | 1 + desktop/src-tauri/src/managed_agents/teams.rs | 262 ++++++------ desktop/src-tauri/src/managed_agents/types.rs | 15 + 17 files changed, 1081 insertions(+), 315 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_templates.rs create mode 100644 desktop/src-tauri/src/managed_agents/flatten.rs create mode 100644 desktop/src-tauri/src/managed_agents/flatten_tests.rs diff --git a/desktop/src-tauri/src/commands/agent_templates.rs b/desktop/src-tauri/src/commands/agent_templates.rs new file mode 100644 index 000000000..c5a1d8434 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_templates.rs @@ -0,0 +1,73 @@ +//! Agent templates and agent-record export. +//! +//! Templates are static starter data for the Create Agent wizard — selecting +//! one prefills the create form; no persona record is involved. Export maps a +//! managed agent's pinned config onto the shareable `.persona.json` card +//! interchange format. + +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{load_managed_agents, load_personas}, +}; + +/// Built-in agent templates for the Create Agent wizard. Static data — no +/// store access, no lock. +#[tauri::command] +pub fn list_agent_templates() -> Vec { + crate::managed_agents::builtin_agent_templates() +} + +/// Export a managed agent's pinned config as a shareable `.persona.json` +/// card (the interchange format). `env_vars` are deliberately excluded — +/// cards are shareable artifacts and must never carry credentials. +#[tauri::command] +pub async fn export_agent_to_json( + pubkey: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Load the record under lock, then drop the lock before the dialog. + let (name, system_prompt, avatar_url, runtime, model, provider) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let personas = load_personas(&app).unwrap_or_default(); + let effective_command = crate::managed_agents::effective_agent_command( + record.persona_id.as_deref(), + &personas, + record.agent_command_override.as_deref(), + ); + let runtime = + crate::managed_agents::known_acp_runtime(&effective_command).map(|r| r.id.to_string()); + ( + record.name.clone(), + record.system_prompt.clone().unwrap_or_default(), + record.avatar_url.clone(), + runtime, + record.model.clone(), + record.provider.clone(), + ) + }; + + let json_bytes = crate::managed_agents::encode_persona_json( + &name, + &system_prompt, + avatar_url.as_deref(), + runtime.as_deref(), + model.as_deref(), + provider.as_deref(), + &[], + )?; + + let slug = crate::util::slugify(&name, "agent", 50); + let filename = format!("{slug}.persona.json"); + super::export_util::save_json_with_dialog(&app, &filename, &json_bytes).await +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index df890931f..c7c21e6a8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -795,7 +795,14 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .map(str::to_string) }), - provider: snapshot_provider, + provider: snapshot_provider.or_else(|| { + input + .provider + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }), persona_source_version: snapshot_source_version, mcp_toolsets: input .mcp_toolsets diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7fea4604a..de18e0102 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ mod agent_discovery; mod agent_models; mod agent_providers; mod agent_settings; +mod agent_templates; mod agents; mod canvas; mod channel_templates; @@ -37,6 +38,7 @@ pub use agent_discovery::*; pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; +pub use agent_templates::*; pub use agents::*; pub use canvas::*; pub use channel_templates::*; diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index aa77b71bf..e4156902a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -4,7 +4,7 @@ use super::*; use std::collections::BTreeMap; -const UUID: &str = "11111111-2222-3333-4444-555555555555"; +const UUID: &str = "11111111-2222-3333-abcd-555555555555"; /// A local in-app persona: `source_team_persona_slug` is None, so its d-tag /// IS its UUID id. Carries env_vars + source_team that must survive a patch. @@ -295,6 +295,7 @@ fn local_team() -> TeamRecord { name: "Local Team".to_string(), description: Some("local desc".to_string()), persona_ids: vec!["p-local".to_string()], + agent_pubkeys: vec!["local-agent-pk".to_string()], is_builtin: false, source_dir: Some(std::path::PathBuf::from("/local/team/dir")), is_symlink: true, @@ -310,6 +311,7 @@ fn team_content(name: &str) -> TeamEventContent { name: name.to_string(), description: Some("remote desc".to_string()), persona_ids: vec!["p-remote-1".to_string(), "p-remote-2".to_string()], + agent_pubkeys: vec!["remote-agent-pk".to_string()], } } diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 4fa517f93..0c31e1a18 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -676,12 +676,14 @@ fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamE local.name = inbound.name; local.description = inbound.description; local.persona_ids = inbound.persona_ids; + local.agent_pubkeys = inbound.agent_pubkeys; } None => teams.push(TeamRecord { id: d_tag, name: inbound.name, description: inbound.description, persona_ids: inbound.persona_ids, + agent_pubkeys: inbound.agent_pubkeys, is_builtin: false, source_dir: None, is_symlink: false, diff --git a/desktop/src-tauri/src/commands/personas/writeback.rs b/desktop/src-tauri/src/commands/personas/writeback.rs index c7a3b170a..9a6e891d7 100644 --- a/desktop/src-tauri/src/commands/personas/writeback.rs +++ b/desktop/src-tauri/src/commands/personas/writeback.rs @@ -773,8 +773,9 @@ You are Paul. name: id.to_string(), description: None, persona_ids: vec![], + agent_pubkeys: vec![], is_builtin: false, - source_dir: source_dir.map(|s| std::path::PathBuf::from(s)), + source_dir: source_dir.map(std::path::PathBuf::from), is_symlink: false, symlink_target: None, version: None, diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 3666d21bd..1980728be 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -28,6 +28,14 @@ fn trim_optional(value: Option) -> Option { }) } +/// Validate and normalize team agent members: each entry must be a 64-char hex +/// pubkey (lowercased, deduped). Existence is NOT checked — a team synced from +/// another device may reference agents that only exist there. +fn normalize_team_agent_pubkeys(input: &[String]) -> Result, String> { + crate::managed_agents::validate_respond_to_allowlist(input) + .map_err(|e| e.replace("respond-to allowlist", "team members")) +} + /// Retain a freshly authored team event in the local store, flagged for relay /// sync. Called inside a command's `managed_agents_store_lock`-held body after /// `save_teams`; the background flush loop publishes it out-of-band. @@ -160,6 +168,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result Result Result(); let name = trim_required(&input.name, "Team name")?; let description = trim_optional(input.description); + let agent_pubkeys = normalize_team_agent_pubkeys(&input.agent_pubkeys)?; let _store_guard = state .managed_agents_store_lock @@ -213,6 +224,7 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Materialize stopped agents for freshly installed/synced pack personas so +/// they immediately appear in the agents-only grid. Best-effort: the install +/// or sync result stands even if materialization fails. Caller MUST hold the +/// managed-agents store lock. +fn materialize_pack_agents_locked(app: &AppHandle, state: &AppState) { + let owner_keys = match state.keys.lock() { + Ok(keys) => keys.clone(), + Err(e) => { + eprintln!("buzz-desktop: team-materialize: keys lock poisoned: {e}"); + return; + } + }; + if let Err(e) = + crate::managed_agents::materialize_agents_for_active_personas_locked(app, &owner_keys) + { + eprintln!("buzz-desktop: team-materialize: {e}"); + } +} + #[tauri::command] pub async fn install_team_from_directory( app: AppHandle, @@ -271,6 +302,7 @@ pub async fn install_team_from_directory( return Err(format!("team path is not a directory: {path}")); } let result = do_import_team(&app, &source, symlink.unwrap_or(false))?; + materialize_pack_agents_locked(&app, &state); try_regenerate_nest(&app); Ok(result) }) @@ -288,6 +320,7 @@ pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result, ) -> Result { - // Load team and personas under lock, then drop lock before dialog. - let (team, personas) = { + // Load team, personas, and agents under lock, then drop lock before dialog. + let (team, personas, agents) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -322,10 +355,11 @@ pub async fn export_team_to_json( .find(|t| t.id == id) .ok_or_else(|| format!("team {id} not found"))?; let personas = load_personas(&app)?; - (team, personas) + let agents = crate::managed_agents::load_managed_agents(&app)?; + (team, personas, agents) }; - let json_bytes = encode_team_json(&team, &personas)?; + let json_bytes = encode_team_json(&team, &personas, &agents)?; let slug = crate::util::slugify(&team.name, "team", 50); let filename = format!("{slug}.team.json"); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 18c987c48..cf9dd432d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -239,6 +239,29 @@ pub fn run() { .lock() .map(|k| k.clone()) .map_err(|e| -> Box { e.to_string().into() })?; + + // Agents-first flattening: materialize stopped agents for + // persona-only cards, rewrite team membership to agent pubkeys, + // pin persona config onto linked agents, and deactivate the + // flattened (non-pack) personas. Runs BEFORE run_event_sync so + // the rewritten teams/personas are what gets reconciled into + // retention and published. Needs the owner keys (auth tags for + // materialized agents). Best-effort — a failure must not block + // launch. + match managed_agents::flatten_personas_into_agents(&app_handle, &owner_keys) { + Ok(report) if !report.is_noop() => { + eprintln!( + "buzz-desktop: flatten: {} agent(s) materialized, {} flattened, {} team(s) rewritten, {} persona(s) deactivated", + report.agents_materialized, + report.agents_flattened, + report.teams_rewritten, + report.personas_deactivated, + ); + } + Ok(_) => {} + Err(e) => eprintln!("buzz-desktop: flatten migration failed: {e}"), + } + migration::run_event_sync(&app_handle, &owner_keys); // Backfill the pinned persona snapshot for any pre-existing agent @@ -545,6 +568,8 @@ pub fn run() { delete_persona, set_persona_active, reconcile_inbound_persona_event, + list_agent_templates, + export_agent_to_json, list_channel_templates, create_channel_template, update_channel_template, diff --git a/desktop/src-tauri/src/managed_agents/flatten.rs b/desktop/src-tauri/src/managed_agents/flatten.rs new file mode 100644 index 000000000..5ecb6649a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/flatten.rs @@ -0,0 +1,398 @@ +//! One-time flattening migration: agents own their config outright. +//! +//! Personas used to be the user-facing template primitive — the Agents grid +//! rendered one card per active persona, and agents linked back via +//! `persona_id`. The agents-first model quarantines persona records to team +//! packs (directory-backed teams genuinely need them for pack sync and +//! writeback); every other agent carries its full config on its own record. +//! +//! The migration, run post-identity on every boot (idempotent, cheap no-op +//! once flattened): +//! +//! 1. **Materialize** a stopped managed agent for every `is_active` persona +//! with no linked agent, so persona-only cards don't visibly disappear. +//! Pack personas keep their `persona_id` link; others are created flat. +//! 2. **Rewrite teams**: non-pack persona members become agent-pubkey members +//! (`TeamRecord.agent_pubkeys`); `persona_ids` remains only for pack teams. +//! 3. **Flatten agents**: for every agent linked to a non-pack persona, pin +//! the live persona config onto the record (mirroring what the next spawn +//! re-snapshot would have done) and clear `persona_id`. +//! 4. **Deactivate** flattened non-pack personas. Records stay on disk and on +//! the relay for rollback; the UI never renders them. + +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +use nostr::{Keys, ToBech32}; +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + effective_agent_command, known_acp_runtime, load_managed_agents, load_personas, load_teams, + managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, + persona_events::persona_snapshot, save_managed_agents, save_personas, save_teams, + ManagedAgentRecord, PersonaRecord, TeamRecord, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + }, + util::now_iso, +}; + +#[derive(Debug, Default, Clone, Copy)] +pub struct FlattenReport { + pub agents_materialized: usize, + pub agents_flattened: usize, + pub teams_rewritten: usize, + pub personas_deactivated: usize, +} + +impl FlattenReport { + pub fn is_noop(&self) -> bool { + self.agents_materialized == 0 + && self.agents_flattened == 0 + && self.teams_rewritten == 0 + && self.personas_deactivated == 0 + } +} + +fn is_pack_persona(persona: &PersonaRecord) -> bool { + persona.source_team.is_some() +} + +/// Compute the NIP-OA auth tag for a freshly generated agent keypair. +fn compute_agent_auth_tag(owner_keys: &Keys, agent_keys: &Keys) -> Result { + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let compat_agent = nostr::PublicKey::from_hex(&agent_keys.public_key().to_hex()) + .map_err(|e| format!("failed to bridge agent pubkey: {e}"))?; + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}")) +} + +/// Build a stopped managed-agent record from a persona's fields. +/// +/// The record is created WITH the `persona_id` link — for non-pack personas +/// the flattening pass (step 3) immediately pins the config and clears the +/// link in the same run; pack personas keep it (plus pack metadata rooted at +/// `teams_base_dir`) so pack sync and ACP pack resolution keep working. +fn materialize_agent_from_persona( + persona: &PersonaRecord, + personas: &[PersonaRecord], + owner_keys: &Keys, + teams_base_dir: Option<&Path>, +) -> Result { + let agent_keys = Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let private_key_nsec = agent_keys + .secret_key() + .to_bech32() + .map_err(|e| format!("failed to encode private key: {e}"))?; + let auth_tag = compute_agent_auth_tag(owner_keys, &agent_keys)?; + + let agent_command = effective_agent_command(Some(&persona.id), personas, None); + let agent_args = normalize_agent_args(&agent_command, Vec::new()); + let mcp_command = known_acp_runtime(&agent_command) + .and_then(|r| r.mcp_command) + .unwrap_or("") + .to_string(); + let avatar_url = persona + .avatar_url + .clone() + .filter(|value| !value.trim().is_empty()) + .or_else(|| managed_agent_avatar_url(&agent_command)); + + let snapshot = persona_snapshot(persona, &BTreeMap::new()); + + let pack_metadata: Option<(std::path::PathBuf, String)> = persona + .source_team + .as_deref() + .zip(persona.source_team_persona_slug.as_deref()) + .zip(teams_base_dir) + .map(|((team_id, slug), base)| (base.join(team_id), slug.to_owned())); + + let now = now_iso(); + Ok(ManagedAgentRecord { + pubkey, + name: persona.display_name.clone(), + persona_id: Some(persona.id.clone()), + private_key_nsec, + auth_tag: Some(auth_tag), + relay_url: String::new(), + avatar_url, + acp_command: DEFAULT_ACP_COMMAND.to_string(), + agent_command: agent_command.clone(), + agent_command_override: None, + agent_args, + mcp_command, + turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: DEFAULT_AGENT_PARALLELISM, + system_prompt: snapshot.system_prompt, + model: snapshot.model, + provider: snapshot.provider, + persona_source_version: Some(snapshot.source_version), + mcp_toolsets: None, + env_vars: snapshot.env_vars, + // Materialized agents come up stopped and stay manual-start: nothing + // should silently boot a fleet of formerly-dormant persona cards. + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: pack_metadata.as_ref().map(|(path, _)| path.clone()), + persona_name_in_team: pack_metadata.map(|(_, slug)| slug), + created_at: now.clone(), + updated_at: now, + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: Default::default(), + respond_to_allowlist: Vec::new(), + relay_mesh: None, + }) +} + +/// Materialize stopped agents for active personas with no linked agent +/// (pure core — mutates `agents` in place, returns the number created). +fn materialize_missing_agents( + personas: &[PersonaRecord], + agents: &mut Vec, + owner_keys: &Keys, + teams_base_dir: Option<&Path>, +) -> usize { + let mut created = 0usize; + for persona in personas.iter().filter(|p| p.is_active) { + let has_agent = agents + .iter() + .any(|agent| agent.persona_id.as_deref() == Some(persona.id.as_str())); + if has_agent { + continue; + } + match materialize_agent_from_persona(persona, personas, owner_keys, teams_base_dir) { + Ok(record) => { + eprintln!( + "buzz-desktop: flatten: materialized stopped agent '{}' for persona {}", + record.name, persona.id + ); + agents.push(record); + created += 1; + } + Err(e) => { + eprintln!( + "buzz-desktop: flatten: failed to materialize agent for persona {}: {e}", + persona.id + ); + } + } + } + created +} + +/// Pure flattening core over in-memory stores. Returns what changed so the +/// IO wrapper can save only touched files. +fn flatten_in_place( + personas: &mut [PersonaRecord], + agents: &mut Vec, + teams: &mut [TeamRecord], + owner_keys: &Keys, + teams_base_dir: Option<&Path>, +) -> FlattenReport { + // ── Step 1: materialize agents for active personas with no agent ──────── + let mut report = FlattenReport { + agents_materialized: materialize_missing_agents( + personas, + agents, + owner_keys, + teams_base_dir, + ), + ..FlattenReport::default() + }; + + // ── Step 2: rewrite team membership for non-pack personas ─────────────── + // First agent per persona id wins as the team-member representative. + let persona_agent: HashMap = { + let mut map = HashMap::new(); + for agent in agents.iter() { + if let Some(pid) = agent.persona_id.as_deref() { + map.entry(pid.to_string()) + .or_insert_with(|| agent.pubkey.clone()); + } + } + map + }; + + for team in teams.iter_mut() { + let mut remaining_persona_ids = Vec::with_capacity(team.persona_ids.len()); + let mut team_changed = false; + for pid in std::mem::take(&mut team.persona_ids) { + let persona = personas.iter().find(|p| p.id == pid); + let keep_as_persona = persona.is_some_and(is_pack_persona); + if keep_as_persona { + remaining_persona_ids.push(pid); + continue; + } + // Non-pack (or dangling) member: swap in the linked agent when one + // exists, otherwise drop the member. + if let Some(pubkey) = persona_agent.get(&pid) { + if !team.agent_pubkeys.iter().any(|pk| pk == pubkey) { + team.agent_pubkeys.push(pubkey.clone()); + } + } else { + eprintln!( + "buzz-desktop: flatten: dropping team member {pid} from '{}' (no agent to migrate to)", + team.name + ); + } + team_changed = true; + } + team.persona_ids = remaining_persona_ids; + if team_changed { + team.updated_at = now_iso(); + report.teams_rewritten += 1; + } + } + + // ── Step 3: flatten agents linked to non-pack personas ────────────────── + for agent in agents.iter_mut() { + let Some(pid) = agent.persona_id.clone() else { + continue; + }; + let Some(persona) = personas.iter().find(|p| p.id == pid) else { + // Orphaned link — the pinned record fields are all that remain. + agent.persona_id = None; + agent.persona_source_version = None; + agent.updated_at = now_iso(); + report.agents_flattened += 1; + continue; + }; + if is_pack_persona(persona) { + continue; + } + + // Pin the live persona config onto the record — exactly what the next + // spawn's re-snapshot would have applied (persona env layered under + // the agent's own env overrides). + let snapshot = persona_snapshot(persona, &agent.env_vars); + if let Some(prompt) = snapshot.system_prompt { + agent.system_prompt = Some(prompt); + } + agent.model = snapshot.model; + agent.provider = snapshot.provider; + agent.env_vars = snapshot.env_vars; + if agent + .avatar_url + .as_deref() + .map(str::trim) + .is_none_or(str::is_empty) + { + agent.avatar_url = persona.avatar_url.clone(); + } + + // Pin the effective harness before the persona link goes away. + let effective_command = effective_agent_command( + Some(&pid), + personas, + agent.agent_command_override.as_deref(), + ); + agent.agent_args = normalize_agent_args(&effective_command, agent.agent_args.clone()); + if agent.mcp_command.trim().is_empty() { + agent.mcp_command = known_acp_runtime(&effective_command) + .and_then(|r| r.mcp_command) + .unwrap_or("") + .to_string(); + } + agent.agent_command = effective_command.clone(); + if agent.agent_command_override.is_none() { + agent.agent_command_override = Some(effective_command); + } + + agent.persona_id = None; + agent.persona_source_version = None; + agent.updated_at = now_iso(); + report.agents_flattened += 1; + } + + // ── Step 4: deactivate flattened non-pack personas ────────────────────── + for persona in personas.iter_mut() { + if !is_pack_persona(persona) && persona.is_active { + persona.is_active = false; + persona.updated_at = now_iso(); + report.personas_deactivated += 1; + } + } + + report +} + +/// Materialize stopped agents for active personas with no linked agent. +/// +/// MUST be called with the `managed_agents_store_lock` already held — this +/// reads and writes the agent store without acquiring it. Used by team pack +/// install/sync so freshly synced pack personas immediately appear as +/// (stopped) agents in the grid. Returns the number of agents created. +pub fn materialize_agents_for_active_personas_locked( + app: &AppHandle, + owner_keys: &Keys, +) -> Result { + let personas = load_personas(app)?; + let mut agents = load_managed_agents(app)?; + let teams_base = managed_agents_base_dir(app)?.join("teams"); + + let created = materialize_missing_agents(&personas, &mut agents, owner_keys, Some(&teams_base)); + if created > 0 { + save_managed_agents(app, &agents)?; + } + Ok(created) +} + +/// Run the full flattening migration. Acquires the store lock itself — call +/// from boot setup after the owner identity is resolved, never from a command +/// that already holds the lock. +pub fn flatten_personas_into_agents( + app: &AppHandle, + owner_keys: &Keys, +) -> Result { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + let mut personas = load_personas(app)?; + let mut agents = load_managed_agents(app)?; + let mut teams = load_teams(app)?; + let teams_base = managed_agents_base_dir(app)?.join("teams"); + + let report = flatten_in_place( + &mut personas, + &mut agents, + &mut teams, + owner_keys, + Some(&teams_base), + ); + + if report.is_noop() { + return Ok(report); + } + + // Reference holders first (teams, agents), then personas — matches the + // crash-ordering convention in `sync_team_personas`. + if report.teams_rewritten > 0 { + save_teams(app, &teams)?; + } + if report.agents_materialized > 0 || report.agents_flattened > 0 { + save_managed_agents(app, &agents)?; + } + if report.personas_deactivated > 0 { + save_personas(app, &personas)?; + } + + Ok(report) +} + +#[cfg(test)] +#[path = "flatten_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/flatten_tests.rs b/desktop/src-tauri/src/managed_agents/flatten_tests.rs new file mode 100644 index 000000000..e31e9ce26 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/flatten_tests.rs @@ -0,0 +1,292 @@ +use std::collections::BTreeMap; + +use nostr::Keys; + +use super::flatten_in_place; +use crate::managed_agents::{ManagedAgentRecord, PersonaRecord, TeamRecord}; + +fn persona(id: &str, name: &str) -> PersonaRecord { + PersonaRecord { + id: id.to_string(), + display_name: name.to_string(), + avatar_url: Some(format!("https://example.com/{id}.png")), + system_prompt: format!("You are {name}."), + runtime: Some("goose".to_string()), + model: Some("claude-test".to_string()), + provider: Some("anthropic".to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::from([("PERSONA_KEY".to_string(), "persona-value".to_string())]), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +fn pack_persona(id: &str, name: &str) -> PersonaRecord { + PersonaRecord { + source_team: Some("com.example.pack".to_string()), + source_team_persona_slug: Some("scout".to_string()), + ..persona(id, name) + } +} + +fn agent(pubkey: &str, name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: pubkey.to_string(), + name: name.to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec!["acp".to_string()], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: Some("hash".to_string()), + mcp_toolsets: None, + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: BTreeMap::from([("AGENT_KEY".to_string(), "agent-value".to_string())]), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + relay_mesh: None, + } +} + +fn team(id: &str, persona_ids: &[&str]) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: id.to_string(), + description: None, + persona_ids: persona_ids.iter().map(|s| s.to_string()).collect(), + agent_pubkeys: Vec::new(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +fn owner_keys() -> Keys { + Keys::generate() +} + +#[test] +fn flattens_persona_backed_agent_and_pins_config() { + let mut personas = vec![persona("p1", "Honey")]; + let mut agents = vec![agent("agent-pk", "Honey", Some("p1"))]; + let mut teams = vec![]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_flattened, 1); + assert_eq!(report.agents_materialized, 0); + let flat = &agents[0]; + assert_eq!(flat.persona_id, None); + assert_eq!(flat.persona_source_version, None); + assert_eq!(flat.system_prompt.as_deref(), Some("You are Honey.")); + assert_eq!(flat.model.as_deref(), Some("claude-test")); + assert_eq!(flat.provider.as_deref(), Some("anthropic")); + // Persona env layered under agent env — both keys survive. + assert_eq!( + flat.env_vars.get("PERSONA_KEY").map(String::as_str), + Some("persona-value") + ); + assert_eq!( + flat.env_vars.get("AGENT_KEY").map(String::as_str), + Some("agent-value") + ); + // Avatar backfilled from the persona. + assert_eq!( + flat.avatar_url.as_deref(), + Some("https://example.com/p1.png") + ); + // Harness pinned so losing the persona link doesn't fall back to default. + assert!(flat.agent_command_override.is_some()); + // Persona deactivated. + assert_eq!(report.personas_deactivated, 1); + assert!(!personas[0].is_active); +} + +#[test] +fn agent_env_overrides_win_over_persona_env() { + let mut personas = vec![persona("p1", "Honey")]; + personas[0] + .env_vars + .insert("SHARED".to_string(), "persona".to_string()); + let mut agents = vec![agent("agent-pk", "Honey", Some("p1"))]; + agents[0] + .env_vars + .insert("SHARED".to_string(), "agent".to_string()); + let mut teams = vec![]; + + flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!( + agents[0].env_vars.get("SHARED").map(String::as_str), + Some("agent") + ); +} + +#[test] +fn materializes_stopped_agent_for_active_persona_without_agent() { + let mut personas = vec![persona("p1", "Honey")]; + let mut agents = vec![]; + let mut teams = vec![]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_materialized, 1); + assert_eq!(agents.len(), 1); + let created = &agents[0]; + assert_eq!(created.name, "Honey"); + // Materialized flat: persona link cleared during flattening. + assert_eq!(created.persona_id, None); + assert_eq!(created.system_prompt.as_deref(), Some("You are Honey.")); + assert!(!created.start_on_app_launch); + assert!(created.auth_tag.is_some()); + assert!(!created.private_key_nsec.is_empty()); + assert_eq!(created.backend, crate::managed_agents::BackendKind::Local); + // Persona ends up deactivated. + assert!(!personas[0].is_active); +} + +#[test] +fn inactive_persona_without_agent_is_not_materialized() { + let mut personas = vec![PersonaRecord { + is_active: false, + ..persona("p1", "Dormant") + }]; + let mut agents = vec![]; + let mut teams = vec![]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_materialized, 0); + assert!(agents.is_empty()); +} + +#[test] +fn pack_personas_and_their_agents_are_untouched() { + let mut personas = vec![pack_persona("pack-p", "Scout")]; + let mut agents = vec![agent("agent-pk", "Scout", Some("pack-p"))]; + let mut teams = vec![]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_flattened, 0); + assert_eq!(report.personas_deactivated, 0); + assert_eq!(agents[0].persona_id.as_deref(), Some("pack-p")); + assert!(personas[0].is_active); +} + +#[test] +fn pack_persona_without_agent_materializes_with_persona_link() { + let mut personas = vec![pack_persona("pack-p", "Scout")]; + let mut agents = vec![]; + let mut teams = vec![]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_materialized, 1); + assert_eq!(agents[0].persona_id.as_deref(), Some("pack-p")); + assert!(agents[0].persona_source_version.is_some()); +} + +#[test] +fn team_persona_members_migrate_to_agent_pubkeys() { + let mut personas = vec![persona("p1", "Honey"), pack_persona("pack-p", "Scout")]; + let mut agents = vec![ + agent("honey-pk", "Honey", Some("p1")), + agent("scout-pk", "Scout", Some("pack-p")), + ]; + let mut teams = vec![team("t1", &["p1", "pack-p"])]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.teams_rewritten, 1); + // Non-pack member became an agent pubkey; pack member stayed a persona id. + assert_eq!(teams[0].agent_pubkeys, vec!["honey-pk".to_string()]); + assert_eq!(teams[0].persona_ids, vec!["pack-p".to_string()]); +} + +#[test] +fn team_member_without_agent_is_materialized_then_migrated() { + let mut personas = vec![persona("p1", "Honey")]; + let mut agents = vec![]; + let mut teams = vec![team("t1", &["p1"])]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_materialized, 1); + assert_eq!(teams[0].persona_ids, Vec::::new()); + assert_eq!(teams[0].agent_pubkeys, vec![agents[0].pubkey.clone()]); +} + +#[test] +fn dangling_team_member_is_dropped() { + let mut personas = vec![]; + let mut agents = vec![]; + let mut teams = vec![team("t1", &["ghost"])]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.teams_rewritten, 1); + assert!(teams[0].persona_ids.is_empty()); + assert!(teams[0].agent_pubkeys.is_empty()); +} + +#[test] +fn orphaned_agent_link_is_cleared() { + let mut personas = vec![]; + let mut agents = vec![agent("agent-pk", "Ghosted", Some("gone"))]; + let mut teams = vec![]; + + let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None); + + assert_eq!(report.agents_flattened, 1); + assert_eq!(agents[0].persona_id, None); +} + +#[test] +fn second_run_is_a_noop() { + let mut personas = vec![persona("p1", "Honey"), pack_persona("pack-p", "Scout")]; + let mut agents = vec![agent("honey-pk", "Honey", Some("p1"))]; + let mut teams = vec![team("t1", &["p1", "pack-p"])]; + + let keys = owner_keys(); + let first = flatten_in_place(&mut personas, &mut agents, &mut teams, &keys, None); + assert!(!first.is_noop()); + + let second = flatten_in_place(&mut personas, &mut agents, &mut teams, &keys, None); + assert!( + second.is_noop(), + "expected idempotent second run, got {second:?}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 68ab8f5a6..8805497a7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -5,6 +5,7 @@ mod backend; pub(crate) mod config_bridge; mod discovery; mod env_vars; +mod flatten; mod nest; mod persona_avatars; mod persona_card; @@ -27,6 +28,7 @@ mod types; pub use backend::*; pub use discovery::*; pub use env_vars::*; +pub use flatten::*; pub use nest::*; pub use persona_card::*; pub use personas::*; diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index e170b9d8a..21d3a789e 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -7,6 +7,21 @@ use crate::{ util::now_iso, }; +/// A built-in agent template: static starter data for the Create Agent +/// wizard. Templates are NOT persona records — selecting one prefills the +/// create form and the submit creates a plain managed agent. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTemplate { + pub id: String, + pub display_name: String, + pub avatar_url: Option, + pub system_prompt: String, + pub runtime: Option, + pub model: Option, + pub name_pool: Vec, +} + struct BuiltInPersona { id: &'static str, display_name: &'static str, @@ -15,7 +30,6 @@ struct BuiltInPersona { name_pool: &'static [&'static str], model: Option<&'static str>, runtime: Option<&'static str>, - default_active: bool, } const FIZZ_AVATAR: &str = "data:image/png;base64,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"; @@ -120,7 +134,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ ], model: None, runtime: Some("goose"), - default_active: true, }, BuiltInPersona { id: "builtin:product-strategist", @@ -130,7 +143,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ name_pool: &["Product Strategist"], model: None, runtime: None, - default_active: false, }, BuiltInPersona { id: "builtin:implementation-partner", @@ -140,7 +152,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ name_pool: &["Implementation Partner"], model: None, runtime: None, - default_active: false, }, BuiltInPersona { id: "builtin:qa-reviewer", @@ -150,7 +161,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ name_pool: &["QA Reviewer"], model: None, runtime: None, - default_active: false, }, BuiltInPersona { id: "builtin:work-coordinator", @@ -160,7 +170,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ name_pool: &["Work Coordinator"], model: None, runtime: None, - default_active: false, }, BuiltInPersona { id: "builtin:support-guide", @@ -170,7 +179,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ name_pool: &["Support Guide"], model: None, runtime: None, - default_active: false, }, BuiltInPersona { id: "builtin:experiment-designer", @@ -180,7 +188,6 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ name_pool: &["Experiment Designer"], model: None, runtime: None, - default_active: false, }, ]; @@ -227,98 +234,43 @@ fn personas_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("personas.json")) } -fn built_in_persona_records(now: &str) -> Vec { +/// Built-in agent templates for the Create Agent wizard. Static data, never +/// stored — selecting a template prefills the create-agent form. +pub fn builtin_agent_templates() -> Vec { BUILT_IN_PERSONAS .iter() - .map(|persona| PersonaRecord { + .map(|persona| AgentTemplate { id: persona.id.to_string(), display_name: persona.display_name.to_string(), avatar_url: persona.avatar_url.map(|s| s.to_string()), system_prompt: persona.system_prompt.to_string(), runtime: persona.runtime.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), - provider: None, name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), - is_builtin: true, - is_active: persona.default_active, - source_team: None, - source_team_persona_slug: None, - env_vars: std::collections::BTreeMap::new(), - created_at: now.to_string(), - updated_at: now.to_string(), }) .collect() } -fn built_in_order(id: &str) -> Option { - BUILT_IN_PERSONAS - .iter() - .position(|persona| persona.id == id) -} - fn sort_personas(records: &mut [PersonaRecord]) { records.sort_by(|left, right| { - let left_builtin = if left.is_builtin { 0 } else { 1 }; - let right_builtin = if right.is_builtin { 0 } else { 1 }; - - left_builtin - .cmp(&right_builtin) - .then_with( - || match (built_in_order(&left.id), built_in_order(&right.id)) { - (Some(left_order), Some(right_order)) => left_order.cmp(&right_order), - _ => std::cmp::Ordering::Equal, - }, - ) - .then_with(|| { - left.display_name - .to_lowercase() - .cmp(&right.display_name.to_lowercase()) - }) + left.display_name + .to_lowercase() + .cmp(&right.display_name.to_lowercase()) .then_with(|| left.id.cmp(&right.id)) }); } +/// Merge pass over the stored personas. Built-in personas are no longer +/// seeded as records — they live on as static agent templates +/// ([`builtin_agent_templates`]) — so any record still flagged `is_builtin` +/// is demoted to a plain custom persona. The record stays so existing +/// managed-agent and team references keep working until the flatten +/// migration rewrites them. fn merge_personas(mut stored: Vec, now: &str) -> (Vec, bool) { let mut changed = false; - for built_in in built_in_persona_records(now) { - if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) { - let created_at = existing.created_at.clone(); - let updated_at = existing.updated_at.clone(); - let is_active = existing.is_active; - // Built-in fields are canonical — user overrides on runtime/model are - // intentionally not preserved across restarts. Users who want a custom - // model or runtime should clone the built-in as a custom persona. - if existing.display_name != built_in.display_name - || existing.avatar_url != built_in.avatar_url - || existing.system_prompt != built_in.system_prompt - || existing.name_pool != built_in.name_pool - || existing.env_vars != built_in.env_vars - || existing.runtime != built_in.runtime - || existing.model != built_in.model - || !existing.is_builtin - { - *existing = PersonaRecord { - created_at, - updated_at, - is_active, - ..built_in - }; - changed = true; - } - } else { - stored.push(built_in); - changed = true; - } - } - - // Demote any stored persona still flagged as built-in whose id is no - // longer in BUILT_IN_PERSONAS (e.g. a built-in that has been retired). - // The record stays so existing managed-agent and team references keep - // working; the user can delete it from the catalog like any custom - // persona once they no longer need it. for record in stored.iter_mut() { - if record.is_builtin && built_in_order(&record.id).is_none() { + if record.is_builtin { record.is_builtin = false; record.updated_at = now.to_string(); changed = true; @@ -385,7 +337,7 @@ pub fn ensure_persona_is_active( if !persona.is_active { return Err(format!( - "{} is not in My Agents. Choose it from Agent Catalog first.", + "{} is not available for new agents.", persona.display_name )); } diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 1566f5f3d..6b17c36b0 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,7 +1,7 @@ use super::{ - ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, - migrate_retired_personas, validate_persona_activation_change, validate_persona_deletion, - BUILT_IN_PERSONAS, RETIRED_PERSONAS, + builtin_agent_templates, ensure_persona_ids_are_active, ensure_persona_is_active, + merge_personas, migrate_retired_personas, validate_persona_activation_change, + validate_persona_deletion, RETIRED_PERSONAS, }; use crate::managed_agents::validate_team_id; use crate::managed_agents::PersonaRecord; @@ -27,21 +27,20 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { } #[test] -fn merge_personas_adds_missing_built_ins() { +fn merge_personas_seeds_nothing() { let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z"); - assert!(changed); - assert_eq!(records.len(), BUILT_IN_PERSONAS.len()); - assert!(records.iter().all(|record| record.is_builtin)); - assert!(records + assert!(!changed); + assert!(records.is_empty()); +} + +#[test] +fn builtin_agent_templates_expose_starter_data() { + let templates = builtin_agent_templates(); + + let display_names: Vec<&str> = templates .iter() - .any(|record| record.id == "builtin:fizz" && record.runtime.as_deref() == Some("goose"))); - assert!(records - .iter() - .any(|record| record.id == "builtin:product-strategist" && !record.is_active)); - let display_names: Vec<&str> = records - .iter() - .map(|record| record.display_name.as_str()) + .map(|template| template.display_name.as_str()) .collect(); assert_eq!( display_names, @@ -55,12 +54,13 @@ fn merge_personas_adds_missing_built_ins() { "Experiment Designer" ] ); - let active_ids: Vec<&str> = records + let fizz = templates .iter() - .filter(|record| record.is_active) - .map(|record| record.id.as_str()) - .collect(); - assert_eq!(active_ids, vec!["builtin:fizz"]); + .find(|template| template.id == "builtin:fizz") + .expect("fizz template should exist"); + assert_eq!(fizz.runtime.as_deref(), Some("goose")); + assert!(!fizz.system_prompt.is_empty()); + assert!(fizz.avatar_url.is_some()); } #[test] @@ -68,94 +68,33 @@ fn merge_personas_preserves_custom_records() { let custom = custom_persona("custom:test", "Custom"); let (records, changed) = merge_personas(vec![custom.clone()], "2026-03-19T00:00:00Z"); - assert!(changed); + assert!(!changed); assert!(records.iter().any(|record| record.id == custom.id)); } #[test] -fn merge_personas_restores_builtin_defaults() { - let mut edited_builtin = custom_persona("builtin:fizz", "My Fizz"); - edited_builtin.is_builtin = true; - edited_builtin.is_active = true; - let original_created_at = edited_builtin.created_at.clone(); - let original_updated_at = edited_builtin.updated_at.clone(); +fn merge_personas_demotes_legacy_builtin_records() { + let mut legacy_builtin = custom_persona("builtin:fizz", "Fizz"); + legacy_builtin.is_builtin = true; + legacy_builtin.is_active = true; + let original_created_at = legacy_builtin.created_at.clone(); - let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z"); + let (records, changed) = merge_personas(vec![legacy_builtin], "2026-03-19T00:00:00Z"); assert!(changed); let fizz = records .iter() .find(|record| record.id == "builtin:fizz") - .expect("fizz built-in should exist"); - let canonical = BUILT_IN_PERSONAS - .iter() - .find(|persona| persona.id == "builtin:fizz") - .expect("fizz built-in definition should exist"); - assert_eq!(fizz.display_name, canonical.display_name); - assert_eq!(fizz.avatar_url.as_deref(), canonical.avatar_url,); + .expect("legacy fizz record should be retained as a custom persona"); + assert!(!fizz.is_builtin); + // Local activation state survives — the flatten migration handles it. + assert!(fizz.is_active); assert_eq!(fizz.created_at, original_created_at); - assert_eq!(fizz.updated_at, original_updated_at); - assert!(fizz.is_active); + assert_eq!(fizz.updated_at, "2026-03-19T00:00:00Z"); } #[test] -fn merge_personas_restores_builtin_env_vars() { - // A hand-edited built-in record with stray env vars should be reset to - // the canonical (empty) env on merge. Built-ins are intended immutable — - // if a user wants per-persona credentials, they create or duplicate to a - // custom persona. - let mut tampered = custom_persona("builtin:fizz", "Fizz"); - tampered.is_builtin = true; - tampered.avatar_url = None; - tampered.is_active = true; - tampered.env_vars = - std::collections::BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "leaked".to_string())]); - - let (records, changed) = merge_personas(vec![tampered], "2026-03-19T00:00:00Z"); - - assert!(changed); - let fizz = records - .iter() - .find(|record| record.id == "builtin:fizz") - .expect("fizz built-in should exist"); - // Built-in persona definitions have no `env_vars` field — they are - // always empty. The merge reset should clear the tampered key entirely. - assert!( - fizz.env_vars.is_empty(), - "expected empty, got {:?}", - fizz.env_vars - ); -} - -#[test] -fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() { - let mut fizz = custom_persona("builtin:fizz", "Fizz"); - fizz.is_builtin = true; - fizz.avatar_url = None; - fizz.is_active = true; - fizz.name_pool = vec!["Definitely Not Fizz".to_string()]; - - let (records, changed) = merge_personas(vec![fizz], "2026-03-19T00:00:00Z"); - - assert!(changed); - let fizz = records - .iter() - .find(|record| record.id == "builtin:fizz") - .expect("fizz built-in should exist"); - let expected_name_pool = BUILT_IN_PERSONAS - .iter() - .find(|persona| persona.id == "builtin:fizz") - .expect("fizz built-in definition should exist") - .name_pool - .iter() - .map(|name| (*name).to_string()) - .collect::>(); - assert_eq!(fizz.name_pool, expected_name_pool); - assert!(fizz.is_active); -} - -#[test] -fn merge_personas_adds_fizz_and_retires_old_builtins_for_existing_store() { +fn merge_personas_retires_old_builtins_for_existing_store() { let mut legacy_builtins = vec![custom_persona("builtin:solo", "Solo")]; for persona in &mut legacy_builtins { persona.is_builtin = true; @@ -165,13 +104,6 @@ fn merge_personas_adds_fizz_and_retires_old_builtins_for_existing_store() { let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z"); assert!(changed); - let fizz = records - .iter() - .find(|record| record.id == "builtin:fizz") - .expect("fizz built-in should exist"); - assert!(fizz.is_builtin); - assert!(fizz.is_active); - let solo = records .iter() .find(|record| record.id == "builtin:solo") @@ -221,10 +153,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() { let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err(); - assert_eq!( - err, - "Fizz is not in My Agents. Choose it from Agent Catalog first." - ); + assert_eq!(err, "Fizz is not available for new agents."); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/team_events.rs b/desktop/src-tauri/src/managed_agents/team_events.rs index b0395f15b..5600496bd 100644 --- a/desktop/src-tauri/src/managed_agents/team_events.rs +++ b/desktop/src-tauri/src/managed_agents/team_events.rs @@ -26,6 +26,10 @@ pub struct TeamEventContent { pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub persona_ids: Vec, + /// Managed-agent members by pubkey. Additive field — events published by + /// older clients simply omit it and parse to an empty list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_pubkeys: Vec, } /// Project a `TeamRecord` onto the content fields published in team events. @@ -36,6 +40,7 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent { name: record.name.clone(), description: record.description.clone(), persona_ids: record.persona_ids.clone(), + agent_pubkeys: record.agent_pubkeys.clone(), } } @@ -87,6 +92,7 @@ mod tests { name: "Test Team".to_string(), description: Some("A test team".to_string()), persona_ids: vec!["p1".to_string(), "p2".to_string()], + agent_pubkeys: vec!["a".repeat(64)], is_builtin: false, source_dir: Some(PathBuf::from("/local/only/path")), is_symlink: true, @@ -143,8 +149,21 @@ mod tests { fn content_round_trips() { let event_content = team_event_content(&sample_team()); let json = serde_json::to_string(&event_content).unwrap(); + assert!(json.contains("\"agent_pubkeys\"")); let restored: TeamEventContent = serde_json::from_str(&json).unwrap(); assert_eq!(restored, event_content); + assert_eq!(restored.agent_pubkeys, vec!["a".repeat(64)]); + } + + #[test] + fn content_from_old_clients_without_agent_pubkeys_parses_empty() { + // agent_pubkeys is additive: events published before the field existed + // must still parse, with membership defaulting to no agent members. + let legacy = r#"{"name":"Old Team","persona_ids":["p1"]}"#; + let restored: TeamEventContent = serde_json::from_str(legacy).unwrap(); + assert_eq!(restored.name, "Old Team"); + assert_eq!(restored.persona_ids, vec!["p1"]); + assert!(restored.agent_pubkeys.is_empty()); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index 48d9fdddf..e6213b3a8 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -260,6 +260,7 @@ mod tests { name: id.to_string(), description: None, persona_ids: Vec::new(), + agent_pubkeys: Vec::new(), is_builtin: false, source_dir: None, is_symlink: false, diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 33de8c7f4..080d840a5 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -38,69 +38,16 @@ fn sort_teams(records: &mut [TeamRecord]) { }); } -struct BuiltInTeam { - id: &'static str, - name: &'static str, - description: Option<&'static str>, - persona_ids: &'static [&'static str], -} - -const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam { - id: "builtin-team:fizz", - name: "Fizz", - description: Some("Fizz works carefully and collaboratively."), - persona_ids: &["builtin:fizz"], -}]; - -fn built_in_team_records(now: &str) -> Vec { - BUILT_IN_TEAMS - .iter() - .map(|team| TeamRecord { - id: team.id.to_string(), - name: team.name.to_string(), - description: team.description.map(|s| s.to_string()), - persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(), - is_builtin: true, - source_dir: None, - is_symlink: false, - symlink_target: None, - version: None, - created_at: now.to_string(), - updated_at: now.to_string(), - }) - .collect() -} - -fn built_in_team_order(id: &str) -> Option { - BUILT_IN_TEAMS.iter().position(|team| team.id == id) -} - -/// Add missing built-in teams, demote stale built-ins, and preserve any -/// user customizations to existing built-in teams (name, description, -/// persona membership). Returns the merged list and whether the store -/// changed. +/// Demote stale built-in teams. There are no seeded built-in teams anymore +/// (the former built-in "Fizz" team was a persona grouping; agents are the +/// user-facing concept now) — any record still flagged `is_builtin` becomes a +/// user-owned custom team they can edit or delete. Returns the merged list +/// and whether the store changed. fn merge_teams(mut stored: Vec, now: &str) -> (Vec, bool) { let mut changed = false; - for built_in in built_in_team_records(now) { - if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) { - if !existing.is_builtin { - existing.is_builtin = true; - existing.updated_at = now.to_string(); - changed = true; - } - } else { - stored.push(built_in); - changed = true; - } - } - - // Demote any stored team flagged as built-in whose id is no longer in - // BUILT_IN_TEAMS (e.g. a built-in that has been retired). The record - // stays so existing references keep working; it becomes a user-owned - // custom team they can edit or delete. for record in stored.iter_mut() { - if record.is_builtin && built_in_team_order(&record.id).is_none() { + if record.is_builtin { record.is_builtin = false; record.updated_at = now.to_string(); changed = true; @@ -325,6 +272,7 @@ pub fn import_team_from_directory( Some(resolved.description) }, persona_ids, + agent_pubkeys: Vec::new(), is_builtin: false, source_dir: Some(dest), is_symlink: use_symlink, @@ -607,33 +555,53 @@ pub fn sync_team_from_dir( } /// Encode a team as a JSON blob for export. The format includes the team's -/// name, description, and the full persona data for each member (so the -/// import side can recreate personas that don't exist locally). -pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result, String> { - let mut missing_persona_ids = Vec::new(); - let mut resolved_personas = Vec::with_capacity(team.persona_ids.len()); +/// name, description, and the full member data (so the import side can +/// recreate agents that don't exist locally). Persona-backed members and +/// agent members serialize into the same `personas` array — the interchange +/// member shape — for backward compatibility with older imports. +pub fn encode_team_json( + team: &TeamRecord, + personas: &[PersonaRecord], + agents: &[crate::managed_agents::ManagedAgentRecord], +) -> Result, String> { + let mut missing_members = Vec::new(); + let mut resolved_members = + Vec::with_capacity(team.persona_ids.len() + team.agent_pubkeys.len()); for persona_id in &team.persona_ids { let Some(persona) = personas .iter() .find(|candidate| candidate.id == *persona_id) else { - missing_persona_ids.push(persona_id.clone()); + missing_members.push(persona_id.clone()); continue; }; - resolved_personas.push(serde_json::json!({ + resolved_members.push(serde_json::json!({ "displayName": persona.display_name, "systemPrompt": persona.system_prompt, "avatarUrl": persona.avatar_url, })); } - if !missing_persona_ids.is_empty() { + for pubkey in &team.agent_pubkeys { + let Some(agent) = agents.iter().find(|candidate| candidate.pubkey == *pubkey) else { + missing_members.push(pubkey.clone()); + continue; + }; + + resolved_members.push(serde_json::json!({ + "displayName": agent.name, + "systemPrompt": agent.system_prompt.clone().unwrap_or_default(), + "avatarUrl": agent.avatar_url, + })); + } + + if !missing_members.is_empty() { return Err(format!( - "Team {} references missing personas: {}. Repair the team before exporting.", + "Team {} references missing members: {}. Repair the team before exporting.", team.name, - missing_persona_ids.join(", ") + missing_members.join(", ") )); } @@ -642,7 +610,7 @@ pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result "type": "team", "name": team.name, "description": team.description, - "personas": resolved_personas, + "personas": resolved_members, }); serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize team JSON: {e}")) @@ -690,6 +658,8 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { .and_then(|v| v.as_str())? .trim() .to_string(); + // An empty prompt is valid for agent members — core memory + // is injected at runtime — but the field must be present. let system_prompt = p .get("systemPrompt") .and_then(|v| v.as_str())? @@ -700,7 +670,7 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { .and_then(|v| v.as_str()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - if display_name.is_empty() || system_prompt.is_empty() { + if display_name.is_empty() { return None; } Some(TeamPersonaPreview { @@ -724,7 +694,6 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { mod tests { use super::{ encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion, - BUILT_IN_TEAMS, }; use crate::managed_agents::{PersonaRecord, TeamRecord}; @@ -734,6 +703,7 @@ mod tests { name: name.to_string(), description: None, persona_ids: Vec::new(), + agent_pubkeys: Vec::new(), is_builtin: false, source_dir: None, is_symlink: false, @@ -802,7 +772,7 @@ mod tests { persona("p2", "Bob", "You are Bob"), ]; - let bytes = encode_team_json(&t, &personas).unwrap(); + let bytes = encode_team_json(&t, &personas, &[]).unwrap(); let parsed = parse_team_json(&bytes).unwrap(); assert_eq!(parsed.name, "My Team"); @@ -822,14 +792,88 @@ mod tests { }; let personas = vec![persona("p1", "Alice", "prompt")]; - let err = encode_team_json(&t, &personas).unwrap_err(); + let err = encode_team_json(&t, &personas, &[]).unwrap_err(); assert_eq!( err, - "Team Team references missing personas: missing. Repair the team before exporting." + "Team Team references missing members: missing. Repair the team before exporting." ); } + fn agent_record( + pubkey: &str, + name: &str, + prompt: Option<&str>, + ) -> crate::managed_agents::ManagedAgentRecord { + crate::managed_agents::ManagedAgentRecord { + pubkey: pubkey.to_string(), + name: name.to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: Some("https://example.com/a.png".to_string()), + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: prompt.map(str::to_string), + model: None, + provider: None, + persona_source_version: None, + mcp_toolsets: None, + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + relay_mesh: None, + } + } + + #[test] + fn encode_includes_agent_members() { + let t = TeamRecord { + agent_pubkeys: vec!["a".repeat(64)], + ..team("t1", "Team") + }; + let agents = vec![agent_record(&"a".repeat(64), "Honey", Some("Be sweet"))]; + + let bytes = encode_team_json(&t, &[], &agents).unwrap(); + let parsed = parse_team_json(&bytes).unwrap(); + + assert_eq!(parsed.personas.len(), 1); + assert_eq!(parsed.personas[0].display_name, "Honey"); + assert_eq!(parsed.personas[0].system_prompt, "Be sweet"); + } + + #[test] + fn encode_errors_for_missing_agent_members() { + let t = TeamRecord { + agent_pubkeys: vec!["b".repeat(64)], + ..team("t1", "Team") + }; + + let err = encode_team_json(&t, &[], &[]).unwrap_err(); + assert!(err.contains("missing members"), "{err}"); + } + #[test] fn parse_team_json_invalid_version() { let json = serde_json::json!({ @@ -898,73 +942,41 @@ mod tests { } #[test] - fn merge_teams_adds_missing_built_ins() { + fn merge_teams_seeds_nothing() { let (records, changed) = merge_teams(Vec::new(), "2026-05-07T00:00:00Z"); - assert!(changed); - assert_eq!(records.len(), BUILT_IN_TEAMS.len()); - assert!(records.iter().all(|record| record.is_builtin)); - let names: Vec<&str> = records.iter().map(|t| t.name.as_str()).collect(); - assert_eq!(names, vec!["Fizz"]); + assert!(!changed); + assert!(records.is_empty()); } #[test] - fn merge_teams_preserves_user_customizations_to_builtin() { - let mut customized = team("builtin-team:fizz", "Fizz (mine)"); - customized.is_builtin = true; - customized.persona_ids = vec!["builtin:fizz".to_string()]; + fn merge_teams_demotes_legacy_built_ins() { + let mut legacy = team("builtin-team:fizz", "Fizz"); + legacy.is_builtin = true; + legacy.persona_ids = vec!["builtin:fizz".to_string()]; - let (records, _changed) = merge_teams(vec![customized], "2026-05-07T00:00:00Z"); + let (records, changed) = merge_teams(vec![legacy], "2026-05-07T00:00:00Z"); + assert!(changed); let fizz = records .iter() .find(|t| t.id == "builtin-team:fizz") - .expect("Fizz built-in should exist"); - assert_eq!(fizz.name, "Fizz (mine)"); + .expect("legacy built-in should be retained as a custom team"); + assert!(!fizz.is_builtin); + assert_eq!(fizz.updated_at, "2026-05-07T00:00:00Z"); + // Membership is preserved; the flatten migration rewrites it to + // agent pubkeys separately. assert_eq!(fizz.persona_ids, vec!["builtin:fizz".to_string()]); - assert!(fizz.is_builtin); } #[test] - fn merge_teams_preserves_unrelated_user_teams() { + fn merge_teams_preserves_user_teams_untouched() { let user_team = team("user-uuid", "My Team"); - let (records, _changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z"); + let (records, changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z"); + assert!(!changed); assert!(records.iter().any(|t| t.id == "user-uuid")); - assert!(records.iter().any(|t| t.id == "builtin-team:fizz")); - } - - #[test] - fn merge_teams_demotes_retired_built_ins() { - let mut retired = team("builtin-team:legacy", "Legacy"); - retired.is_builtin = true; - - let (records, changed) = merge_teams(vec![retired], "2026-05-07T00:00:00Z"); - - assert!(changed); - let demoted = records - .iter() - .find(|t| t.id == "builtin-team:legacy") - .expect("retired built-in should be retained as a custom team"); - assert!(!demoted.is_builtin); - assert_eq!(demoted.updated_at, "2026-05-07T00:00:00Z"); - } - - #[test] - fn merge_teams_repromotes_existing_builtin_marked_as_custom() { - // If someone hand-edits the store and flips is_builtin to false on a - // canonical built-in id, merge_teams should restore the flag. - let mut downgraded = team("builtin-team:fizz", "Fizz"); - downgraded.is_builtin = false; - - let (records, changed) = merge_teams(vec![downgraded], "2026-05-07T00:00:00Z"); - - assert!(changed); - let fizz = records - .iter() - .find(|t| t.id == "builtin-team:fizz") - .expect("Fizz should exist"); - assert!(fizz.is_builtin); + assert_eq!(records.len(), 1); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e17a923b2..ed365c9e6 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -327,6 +327,10 @@ pub struct CreateManagedAgentRequest { pub system_prompt: Option, pub avatar_url: Option, pub model: Option, + /// LLM inference provider for flat (persona-less) creates. A linked + /// persona's snapshot provider wins when both are present. + #[serde(default)] + pub provider: Option, pub mcp_toolsets: Option, /// Environment variables for this agent. Layered on top of persona env. #[serde(default)] @@ -555,7 +559,14 @@ pub struct TeamRecord { pub id: String, pub name: String, pub description: Option, + /// Persona-backed members. Only pack-installed (directory-backed) teams + /// still reference personas; ad-hoc teams use `agent_pubkeys`. + #[serde(default)] pub persona_ids: Vec, + /// Managed-agent members, referenced by agent pubkey (hex). The primary + /// membership mechanism for user-created teams. + #[serde(default)] + pub agent_pubkeys: Vec, #[serde(default)] pub is_builtin: bool, /// Absolute path to the team's backing directory (if directory-backed). @@ -581,6 +592,8 @@ pub struct CreateTeamRequest { pub description: Option, #[serde(default)] pub persona_ids: Vec, + #[serde(default)] + pub agent_pubkeys: Vec, } #[derive(Debug, Deserialize)] @@ -591,6 +604,8 @@ pub struct UpdateTeamRequest { pub description: Option, #[serde(default)] pub persona_ids: Vec, + #[serde(default)] + pub agent_pubkeys: Vec, } /// Result of syncing a directory-backed team with its backing directory.