diff --git a/desktop/src-tauri/src/commands/agent_templates.rs b/desktop/src-tauri/src/commands/agent_templates.rs index c5a1d8434..9ff35ca95 100644 --- a/desktop/src-tauri/src/commands/agent_templates.rs +++ b/desktop/src-tauri/src/commands/agent_templates.rs @@ -1,22 +1,137 @@ //! 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. +//! Templates are starter data for the Create Agent wizard — selecting one +//! prefills the create form; the submit creates a plain managed agent. +//! Built-ins are static; saved templates are persona records (relay-synced). +//! Export maps a managed agent's pinned config onto the shareable +//! `.persona.json` card interchange format. -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use crate::{ app_state::AppState, - managed_agents::{load_managed_agents, load_personas}, + managed_agents::{ + agent_template_from_persona, builtin_agent_templates, load_managed_agents, load_personas, + save_personas, try_regenerate_nest, AgentTemplate, PersonaRecord, + }, + util::now_iso, }; -/// Built-in agent templates for the Create Agent wizard. Static data — no -/// store access, no lock. +/// Templates for the Create Agent wizard: static built-ins followed by saved +/// templates (active persona records), sorted by display name. A saved record +/// whose id shadows a built-in id (a demoted legacy built-in copy) is skipped +/// so the catalog never shows the same starter twice. #[tauri::command] -pub fn list_agent_templates() -> Vec { - crate::managed_agents::builtin_agent_templates() +pub async fn list_agent_templates(app: AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut templates = builtin_agent_templates(); + let mut saved: Vec = load_personas(&app)? + .iter() + .filter(|persona| persona.is_active) + .filter(|persona| !templates.iter().any(|builtin| builtin.id == persona.id)) + .map(agent_template_from_persona) + .collect(); + saved.sort_by(|a, b| { + a.display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()) + .then_with(|| a.id.cmp(&b.id)) + }); + templates.extend(saved); + Ok(templates) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Save a managed agent's pinned config as a reusable template (a persona +/// record) so it shows up in the New Agent catalog. An existing active +/// in-app template with the same display name is updated in place — saving +/// the same agent twice refreshes the template instead of duplicating it. +/// `env_vars` are deliberately excluded: templates are shareable definitions +/// and must never carry credentials. The record is retained for relay sync +/// (kind:30175), so the template reaches the owner's other devices. +#[tauri::command] +pub async fn save_agent_as_template( + pubkey: String, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + 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 name = record.name.trim().to_string(); + if name.is_empty() { + return Err("agent has no name to save as a template".to_string()); + } + + let mut personas = load_personas(&app)?; + 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()); + let now = now_iso(); + + let persona = match personas.iter_mut().find(|p| { + p.is_active + && p.source_team.is_none() + && p.display_name.trim().eq_ignore_ascii_case(&name) + }) { + Some(existing) => { + existing.display_name = name; + existing.avatar_url = record.avatar_url.clone(); + existing.system_prompt = record.system_prompt.clone().unwrap_or_default(); + existing.runtime = runtime; + existing.model = record.model.clone(); + existing.provider = record.provider.clone(); + existing.updated_at = now; + existing.clone() + } + None => { + let persona = PersonaRecord { + id: uuid::Uuid::new_v4().to_string(), + display_name: name, + avatar_url: record.avatar_url.clone(), + system_prompt: record.system_prompt.clone().unwrap_or_default(), + runtime, + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + created_at: now.clone(), + updated_at: now, + }; + personas.push(persona.clone()); + persona + } + }; + save_personas(&app, &personas)?; + super::personas::retain_persona_pending(&app, &state, &persona); + try_regenerate_nest(&app); + Ok(agent_template_from_persona(&persona)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } /// Export a managed agent's pinned config as a shareable `.persona.json` diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0c31e1a18..20cefed80 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -47,7 +47,7 @@ fn trim_optional(value: Option) -> Option { /// 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) { +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}, diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 1980728be..42c567819 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -265,25 +265,6 @@ pub async fn delete_team(id: String, app: AppHandle) -> 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, @@ -302,7 +283,6 @@ 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) }) @@ -320,7 +300,6 @@ pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result 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 @@ -569,6 +547,7 @@ pub fn run() { set_persona_active, reconcile_inbound_persona_event, list_agent_templates, + save_agent_as_template, export_agent_to_json, list_channel_templates, create_channel_template, diff --git a/desktop/src-tauri/src/managed_agents/flatten.rs b/desktop/src-tauri/src/managed_agents/flatten.rs deleted file mode 100644 index 5ecb6649a..000000000 --- a/desktop/src-tauri/src/managed_agents/flatten.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! 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 deleted file mode 100644 index e31e9ce26..000000000 --- a/desktop/src-tauri/src/managed_agents/flatten_tests.rs +++ /dev/null @@ -1,292 +0,0 @@ -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 8805497a7..68ab8f5a6 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -5,7 +5,6 @@ mod backend; pub(crate) mod config_bridge; mod discovery; mod env_vars; -mod flatten; mod nest; mod persona_avatars; mod persona_card; @@ -28,7 +27,6 @@ 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 21d3a789e..4c16d4639 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -7,9 +7,11 @@ 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. +/// An agent template: starter data for the Create Agent wizard. Selecting +/// one prefills the create form and the submit creates a plain managed +/// agent. Built-in templates are static data; saved templates are backed by +/// persona records (relay-synced, kind:30175) but the wizard treats both +/// identically. #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentTemplate { @@ -19,7 +21,18 @@ pub struct AgentTemplate { pub system_prompt: String, pub runtime: Option, pub model: Option, + pub provider: Option, pub name_pool: Vec, + pub source: AgentTemplateSource, +} + +/// Where a template comes from: compiled-in starter data or a saved persona +/// record (device-local + relay-synced). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentTemplateSource { + Builtin, + Saved, } struct BuiltInPersona { @@ -246,11 +259,30 @@ pub fn builtin_agent_templates() -> Vec { 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(), + source: AgentTemplateSource::Builtin, }) .collect() } +/// Map a saved persona record onto the template shape the Create Agent +/// wizard consumes. `env_vars` are deliberately not part of a template — +/// they may carry credentials and never prefill the wizard. +pub fn agent_template_from_persona(persona: &PersonaRecord) -> AgentTemplate { + AgentTemplate { + id: persona.id.clone(), + display_name: persona.display_name.clone(), + avatar_url: persona.avatar_url.clone(), + system_prompt: persona.system_prompt.clone(), + runtime: persona.runtime.clone(), + model: persona.model.clone(), + provider: persona.provider.clone(), + name_pool: persona.name_pool.clone(), + source: AgentTemplateSource::Saved, + } +} + fn sort_personas(records: &mut [PersonaRecord]) { records.sort_by(|left, right| { left.display_name @@ -264,8 +296,7 @@ fn sort_personas(records: &mut [PersonaRecord]) { /// 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. +/// managed-agent and team references keep working. fn merge_personas(mut stored: Vec, now: &str) -> (Vec, bool) { let mut changed = false; diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 6b17c36b0..6ce08ee5f 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -87,7 +87,7 @@ fn merge_personas_demotes_legacy_builtin_records() { .find(|record| record.id == "builtin:fizz") .expect("legacy fizz record should be retained as a custom persona"); assert!(!fizz.is_builtin); - // Local activation state survives — the flatten migration handles it. + // Local activation state survives demotion untouched. assert!(fizz.is_active); assert_eq!(fizz.created_at, original_created_at); assert_eq!(fizz.updated_at, "2026-03-19T00:00:00Z"); diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 080d840a5..b908abcec 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -964,8 +964,7 @@ mod tests { .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. + // Membership is preserved as-is — demotion never rewrites members. assert_eq!(fizz.persona_ids, vec!["builtin:fizz".to_string()]); } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index a452e1ed3..aa7227345 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -33,6 +33,7 @@ import { import { exportAgentToJson, listAgentTemplates, + saveAgentAsTemplate, setManagedAgentStartOnAppLaunch, } from "@/shared/api/tauriManagedAgents"; import { @@ -196,6 +197,19 @@ export function useExportAgentJsonMutation() { }); } +export function useSaveAgentAsTemplateMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (pubkey: string) => saveAgentAsTemplate(pubkey), + onSuccess: () => { + // Refresh the New Agent catalog so the saved template appears without + // reopening the app. Personas back saved templates, so refresh both. + void queryClient.invalidateQueries({ queryKey: agentTemplatesQueryKey }); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + }, + }); +} + export function useManagedAgentPrereqsQuery( acpCommand: string, mcpCommand: string, diff --git a/desktop/src/features/agents/ui/AgentActionsMenu.tsx b/desktop/src/features/agents/ui/AgentActionsMenu.tsx index 52e7b8c68..52c2add4a 100644 --- a/desktop/src/features/agents/ui/AgentActionsMenu.tsx +++ b/desktop/src/features/agents/ui/AgentActionsMenu.tsx @@ -1,4 +1,5 @@ import { + BookmarkPlus, CopyPlus, Ellipsis, FileDown, @@ -24,6 +25,7 @@ export function AgentActionsMenu({ onDuplicate, onEdit, onExport, + onSaveAsTemplate, }: { agent: ManagedAgent; disabled: boolean; @@ -32,6 +34,7 @@ export function AgentActionsMenu({ onDuplicate: (agent: ManagedAgent) => void; onEdit: (agent: ManagedAgent) => void; onExport: (agent: ManagedAgent) => void; + onSaveAsTemplate: (agent: ManagedAgent) => void; }) { return ( @@ -66,6 +69,13 @@ export function AgentActionsMenu({ Add to channel + onSaveAsTemplate(agent)} + > + + Save as template + onExport(agent)}> Export diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a19ee2f96..1a08f2493 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -106,6 +106,7 @@ export function AgentsView() { onDuplicateAgent={agents.openDuplicateAgent} onEditAgent={setAgentToEdit} onExportAgent={agents.handleExportAgent} + onSaveAgentAsTemplate={agents.handleSaveAsTemplate} onImportAgentFile={(fileBytes, fileName) => { void agents.handleImportAgentFile(fileBytes, fileName); }} diff --git a/desktop/src/features/agents/ui/CreateAgentStartDialog.tsx b/desktop/src/features/agents/ui/CreateAgentStartDialog.tsx index 05f8af9a8..41cd8a45f 100644 --- a/desktop/src/features/agents/ui/CreateAgentStartDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentStartDialog.tsx @@ -76,6 +76,12 @@ export function CreateAgentStartDialog({ templates[0] ); }, [templates, selectedTemplateId]); + const builtinTemplates = templates.filter( + (template) => template.source === "builtin", + ); + const savedTemplates = templates.filter( + (template) => template.source === "saved", + ); React.useEffect(() => { if (!open) { @@ -172,40 +178,36 @@ export function CreateAgentStartDialog({ {isLoading ? : null} - {!isLoading && templates.length > 0 ? ( + {!isLoading && builtinTemplates.length > 0 ? (
- {templates.map((template) => { - const isCurrent = template.id === selectedTemplate?.id; - - return ( - - ); - })} + {builtinTemplates.map((template) => ( + + ))}
) : null} + + {!isLoading && savedTemplates.length > 0 ? ( + <> +

+ Saved +

+
+ {savedTemplates.map((template) => ( + + ))} +
+ + ) : null} @@ -272,6 +274,42 @@ export function CreateAgentStartDialog({ ); } +function TemplateRow({ + template, + isCurrent, + onSelect, +}: { + template: AgentTemplate; + isCurrent: boolean; + onSelect: (templateId: string) => void; +}) { + return ( + + ); +} + function TemplateDetail({ template }: { template: AgentTemplate }) { return (
@@ -290,7 +328,13 @@ function TemplateDetail({ template }: { template: AgentTemplate }) { void; onEditAgent: (agent: ManagedAgent) => void; onExportAgent: (agent: ManagedAgent) => void; + onSaveAgentAsTemplate: (agent: ManagedAgent) => void; onImportAgentFile: (fileBytes: number[], fileName: string) => void; }; @@ -68,6 +69,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDuplicateAgent, onEditAgent, onExportAgent, + onSaveAgentAsTemplate, onImportAgentFile, } = props; @@ -124,6 +126,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDuplicate={onDuplicateAgent} onEdit={onEditAgent} onExport={onExportAgent} + onSaveAsTemplate={onSaveAgentAsTemplate} /> } agent={agent} diff --git a/desktop/src/features/agents/ui/agentDraft.ts b/desktop/src/features/agents/ui/agentDraft.ts index 19a54059b..f2bc88f0f 100644 --- a/desktop/src/features/agents/ui/agentDraft.ts +++ b/desktop/src/features/agents/ui/agentDraft.ts @@ -74,7 +74,7 @@ export function templateAgentDraft(template: AgentTemplate): AgentDraft { systemPrompt: template.systemPrompt, runtime: template.runtime, model: template.model, - provider: null, + provider: template.provider, envVars: {}, }; } diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index cf966bb30..5009f8ec9 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -6,6 +6,7 @@ import { useManagedAgentLogQuery, useManagedAgentsQuery, useRelayAgentsQuery, + useSaveAgentAsTemplateMutation, useSetManagedAgentStartOnAppLaunchMutation, useStartManagedAgentMutation, useStopManagedAgentMutation, @@ -52,6 +53,7 @@ export function useManagedAgentActions() { const deleteMutation = useDeleteManagedAgentMutation(); const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); const exportAgentJsonMutation = useExportAgentJsonMutation(); + const saveAsTemplateMutation = useSaveAgentAsTemplateMutation(); const [isCreateStartOpen, setIsCreateStartOpen] = React.useState(false); const [createDraft, setCreateDraft] = React.useState(null); const [batchImportResult, setBatchImportResult] = @@ -250,6 +252,24 @@ export function useManagedAgentActions() { }); } + function handleSaveAsTemplate(agent: ManagedAgent) { + clearFeedback(); + saveAsTemplateMutation.mutate(agent.pubkey, { + onSuccess: (template) => { + setActionNoticeMessage( + `Saved ${template.displayName} as a template. It will show under New agent.`, + ); + }, + onError: (error) => { + setActionErrorMessage( + error instanceof Error + ? error.message + : "Failed to save agent as a template.", + ); + }, + }); + } + async function getChannelsForAction() { if (channelsQuery.data) { return channelsQuery.data; @@ -437,6 +457,7 @@ export function useManagedAgentActions() { stopMutation.isPending || startOnLaunchMutation.isPending || exportAgentJsonMutation.isPending || + saveAsTemplateMutation.isPending || deleteMutation.isPending; const startingAgentPubkey = startMutation.isPending && typeof startMutation.variables === "string" @@ -478,6 +499,7 @@ export function useManagedAgentActions() { handleImportAgentFile, handleBatchImportComplete, handleExportAgent, + handleSaveAsTemplate, handleStart, handleStop, handleDelete, diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 0c2ece0bd..1087e48bc 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -19,11 +19,22 @@ export async function setManagedAgentStartOnAppLaunch( return fromRawManagedAgent(response); } -/** Built-in starter templates for the Create Agent wizard (static data). */ +/** Templates for the Create Agent wizard: built-ins plus saved templates. */ export async function listAgentTemplates(): Promise { return invokeTauri("list_agent_templates"); } +/** + * Save a managed agent's pinned config as a reusable template so it shows + * in the New Agent catalog. Re-saving an agent with the same name updates + * the existing template instead of duplicating it. + */ +export async function saveAgentAsTemplate( + pubkey: string, +): Promise { + return invokeTauri("save_agent_as_template", { pubkey }); +} + /** * Export a managed agent's pinned config as a shareable `.persona.json` card. * Returns `false` when the user cancels the save dialog. diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 0dba3e2d3..5c516f5f8 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -648,9 +648,9 @@ export type UpdatePersonaInput = { }; /** - * A built-in agent template: static starter data for the Create Agent - * wizard. Selecting a template prefills the create form — no persona record - * is created. + * An agent template for the Create Agent wizard: a static built-in starter + * or a saved template backed by a persona record. Selecting a template + * prefills the create form; the submit creates a plain managed agent. */ export type AgentTemplate = { id: string; @@ -659,7 +659,10 @@ export type AgentTemplate = { systemPrompt: string; runtime: string | null; model: string | null; + provider: string | null; namePool: string[]; + /** "builtin" = static starter data; "saved" = persona record (relay-synced). */ + source: "builtin" | "saved"; }; // ── Team types ──────────────────────────────────────────────────────────────── diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 265b0e608..3ce28af73 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -5884,11 +5884,13 @@ type MockAgentTemplate = { systemPrompt: string; runtime: string | null; model: string | null; + provider: string | null; namePool: string[]; + source: "builtin" | "saved"; }; async function handleListAgentTemplates(): Promise { - return [ + const builtins: MockAgentTemplate[] = [ { id: "builtin:fizz", displayName: "Fizz", @@ -5896,7 +5898,9 @@ async function handleListAgentTemplates(): Promise { systemPrompt: "You are Fizz.", runtime: "goose", model: null, + provider: null, namePool: ["Nectar", "Comet", "Bramble"], + source: "builtin", }, { id: "builtin:product-strategist", @@ -5906,7 +5910,9 @@ async function handleListAgentTemplates(): Promise { "You are a product strategy agent. You help turn broad ideas into clear product and design direction.", runtime: null, model: null, + provider: null, namePool: ["Product Strategist"], + source: "builtin", }, { id: "builtin:qa-reviewer", @@ -5916,9 +5922,35 @@ async function handleListAgentTemplates(): Promise { "You are a QA reviewer agent. You look for the ways a change might break.", runtime: null, model: null, + provider: null, namePool: ["QA Reviewer"], + source: "builtin", }, ]; + // Mirror the real command: active personas surface as saved templates, + // skipping records that shadow a built-in id, sorted by display name. + // The mock also skips its seeded is_builtin records — the real store no + // longer contains built-in personas at all. + const saved: MockAgentTemplate[] = mockPersonas + .filter((persona) => persona.is_active && !persona.is_builtin) + .filter((persona) => !builtins.some((builtin) => builtin.id === persona.id)) + .map((persona) => ({ + id: persona.id, + displayName: persona.display_name, + avatarUrl: persona.avatar_url ?? null, + systemPrompt: persona.system_prompt, + runtime: persona.runtime ?? null, + model: persona.model ?? null, + provider: persona.provider ?? null, + namePool: persona.name_pool ?? [], + source: "saved" as const, + })) + .sort((a, b) => + a.displayName + .toLowerCase() + .localeCompare(b.displayName.toLowerCase(), "en"), + ); + return [...builtins, ...saved]; } async function handleExportAgentToJson(args: { @@ -5933,6 +5965,66 @@ async function handleExportAgentToJson(args: { return true; } +async function handleSaveAgentAsTemplate(args: { + pubkey: string; +}): Promise { + const agent = mockManagedAgents.find( + (candidate) => candidate.pubkey === args.pubkey, + ); + if (!agent) { + throw new Error(`agent ${args.pubkey} not found`); + } + const name = agent.name.trim(); + if (!name) { + throw new Error("agent has no name to save as a template"); + } + const now = new Date().toISOString(); + // Mirror the real command: update an existing active in-app persona with + // the same display name, otherwise insert a fresh record. + let persona = mockPersonas.find( + (candidate) => + candidate.is_active && + !candidate.source_team && + candidate.display_name.trim().toLowerCase() === name.toLowerCase(), + ); + if (persona) { + persona.display_name = name; + persona.avatar_url = agent.avatar_url; + persona.system_prompt = agent.system_prompt ?? ""; + persona.runtime = agent.agent_command || null; + persona.model = agent.model; + persona.updated_at = now; + } else { + persona = { + id: crypto.randomUUID(), + display_name: name, + avatar_url: agent.avatar_url, + system_prompt: agent.system_prompt ?? "", + runtime: agent.agent_command || null, + model: agent.model, + provider: null, + name_pool: [], + is_builtin: false, + is_active: true, + source_team: null, + created_at: now, + updated_at: now, + }; + mockPersonas.push(persona); + } + return { + id: persona.id, + displayName: persona.display_name, + avatarUrl: persona.avatar_url ?? null, + systemPrompt: persona.system_prompt, + runtime: persona.runtime ?? null, + model: persona.model ?? null, + provider: persona.provider ?? null, + namePool: persona.name_pool ?? [], + source: "saved", + }; +} + async function handleListTeams(): Promise { return mockTeams.map((team) => ({ ...team, @@ -7855,6 +7947,8 @@ export function maybeInstallE2eTauriMocks() { return handleListAgentTemplates(); case "export_agent_to_json": return handleExportAgentToJson(payload as { pubkey: string }); + case "save_agent_as_template": + return handleSaveAgentAsTemplate(payload as { pubkey: string }); case "list_managed_agents": return handleListManagedAgents(activeConfig); case "get_agent_memory": diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index befc0c5cb..650860144 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -197,7 +197,7 @@ test("blank starting point opens an empty create agent form", async ({ await expect(page.getByTestId("create-agent-submit")).toBeDisabled(); }); -test("agent cards expose edit, duplicate, channel, export, and remove actions", async ({ +test("agent cards expose edit, duplicate, channel, template, export, and remove actions", async ({ page, }) => { await installMockBridge(page, { @@ -221,11 +221,50 @@ test("agent cards expose edit, duplicate, channel, export, and remove actions", "Edit", "Duplicate", "Add to channel", + "Save as template", "Export", "Remove", ]); }); +test("save as template surfaces the agent in the create wizard's Saved group", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "Honey", + status: "stopped", + }, + ], + }); + await gotoApp(page); + + await page.getByTestId("open-agents-view").click(); + await page.getByLabel("Open actions for Honey").click(); + await page.getByRole("menuitem", { name: "Save as template" }).click(); + + await expect( + page.getByText("Saved Honey as a template", { exact: false }), + ).toBeVisible(); + + await openCreateAgentStart(page); + await expect( + page.getByTestId("create-agent-start-dialog-scroll-area"), + ).toContainText("Saved"); + const savedRow = page + .getByTestId("create-agent-start-dialog-scroll-area") + .getByRole("button", { name: "Honey" }); + await expect(savedRow).toBeVisible(); + + // Selecting the saved template shows its detail pane with the Saved type. + await savedRow.click(); + await expect( + page.getByTestId("create-agent-start-detail-pane"), + ).toContainText("Saved template"); +}); + test("duplicate opens the create form prefilled from the agent", async ({ page, }) => {