mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): personas as catalog templates — unwind boot-time agent minting
Agents are now only ever minted explicitly through the Create Agent
wizard. Personas surface exclusively inside the New Agent catalog as
"saved templates" instead of rendering as cards on the Agents grid.
Remove the flatten/materialization migration:
- Delete managed_agents/flatten.rs and flatten_tests.rs — the boot-time
migration minted a fresh keypair for every active persona with no
linked agent, but keyed dedup on the persona_id link it erased itself,
so relay persona echoes from other builds (dev vs release, d-tag
drift) re-minted duplicate agents on every launch on both sides
- Remove the flatten_personas_into_agents boot hookup in lib.rs
- Remove materialize_pack_agents_locked from team pack install/sync in
commands/teams.rs — installing a pack now lands its personas in the
catalog without minting agents
Catalog = built-ins + saved templates:
- list_agent_templates merges static built-ins with active persona
records (store lock held, spawn_blocking), skipping records that
shadow a built-in id and sorting saved entries by display name
- AgentTemplate gains provider and source ("builtin" | "saved") on both
the Rust struct and the TS type; templateAgentDraft carries provider
through to the create form prefill
- CreateAgentStartDialog renders saved templates under a "Saved"
sidebar group; detail pane labels the type accordingly
"Save as template" on minted agents:
- New save_agent_as_template Tauri command maps an agent's pinned
config (name, instructions, avatar, runtime, model, provider — never
env_vars) onto a persona record; re-saving the same name updates the
existing template instead of duplicating; retained as kind:30175
pending relay sync so templates reach other devices
- AgentActionsMenu gains a "Save as template" entry wired through
UnifiedAgentsSection/AgentsView with success toast and
agent-templates/personas query invalidation
Tests:
- e2eBridge mocks the merged template list and save_agent_as_template
- agents.spec.ts covers the new menu entry and the save-as-template →
Saved group → detail pane flow
- Stale flatten references in persona/teams test comments cleaned up
873 Tauri unit tests, 69 Playwright tests, and the full just ci gate
pass.
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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::AgentTemplate> {
|
||||
crate::managed_agents::builtin_agent_templates()
|
||||
pub async fn list_agent_templates(app: AppHandle) -> Result<Vec<AgentTemplate>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
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<AgentTemplate> = 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<AgentTemplate, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
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`
|
||||
|
||||
@@ -47,7 +47,7 @@ fn trim_optional(value: Option<String>) -> Option<String> {
|
||||
/// 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},
|
||||
|
||||
@@ -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<Sync
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = do_sync_team(&app, &team_id)?;
|
||||
materialize_pack_agents_locked(&app, &state);
|
||||
try_regenerate_nest(&app);
|
||||
Ok(result)
|
||||
})
|
||||
|
||||
@@ -240,28 +240,6 @@ pub fn run() {
|
||||
.map(|k| k.clone())
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { 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,
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<ManagedAgentRecord, String> {
|
||||
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<ManagedAgentRecord>,
|
||||
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<ManagedAgentRecord>,
|
||||
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<String, String> = {
|
||||
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<usize, String> {
|
||||
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<FlattenReport, String> {
|
||||
let state = app.state::<AppState>();
|
||||
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;
|
||||
@@ -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::<String>::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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub name_pool: Vec<String>,
|
||||
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<AgentTemplate> {
|
||||
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<PersonaRecord>, now: &str) -> (Vec<PersonaRecord>, bool) {
|
||||
let mut changed = false;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -66,6 +69,13 @@ export function AgentActionsMenu({
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
Add to channel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => onSaveAsTemplate(agent)}
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
Save as template
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={disabled} onClick={() => onExport(agent)}>
|
||||
<FileDown className="h-4 w-4" />
|
||||
Export
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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 ? <TemplateListSkeleton /> : null}
|
||||
|
||||
{!isLoading && templates.length > 0 ? (
|
||||
{!isLoading && builtinTemplates.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{templates.map((template) => {
|
||||
const isCurrent = template.id === selectedTemplate?.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-current={isCurrent ? "true" : undefined}
|
||||
className={cn(
|
||||
sidebarRowClassName,
|
||||
isCurrent
|
||||
? "bg-sidebar-active text-sidebar-active-foreground"
|
||||
: sidebarRowIdleClassName,
|
||||
)}
|
||||
data-testid={`create-agent-template-${template.id}`}
|
||||
key={template.id}
|
||||
onClick={() => {
|
||||
setSelectedTemplateId(template.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatarUrl={template.avatarUrl}
|
||||
className="h-6 w-6 text-3xs"
|
||||
label={template.displayName}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{template.displayName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{builtinTemplates.map((template) => (
|
||||
<TemplateRow
|
||||
isCurrent={template.id === selectedTemplate?.id}
|
||||
key={template.id}
|
||||
onSelect={setSelectedTemplateId}
|
||||
template={template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && savedTemplates.length > 0 ? (
|
||||
<>
|
||||
<p className="px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wide text-sidebar-foreground/60">
|
||||
Saved
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{savedTemplates.map((template) => (
|
||||
<TemplateRow
|
||||
isCurrent={template.id === selectedTemplate?.id}
|
||||
key={template.id}
|
||||
onSelect={setSelectedTemplateId}
|
||||
template={template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +274,42 @@ export function CreateAgentStartDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateRow({
|
||||
template,
|
||||
isCurrent,
|
||||
onSelect,
|
||||
}: {
|
||||
template: AgentTemplate;
|
||||
isCurrent: boolean;
|
||||
onSelect: (templateId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-current={isCurrent ? "true" : undefined}
|
||||
className={cn(
|
||||
sidebarRowClassName,
|
||||
isCurrent
|
||||
? "bg-sidebar-active text-sidebar-active-foreground"
|
||||
: sidebarRowIdleClassName,
|
||||
)}
|
||||
data-testid={`create-agent-template-${template.id}`}
|
||||
onClick={() => {
|
||||
onSelect(template.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatarUrl={template.avatarUrl}
|
||||
className="h-6 w-6 text-3xs"
|
||||
label={template.displayName}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{template.displayName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateDetail({ template }: { template: AgentTemplate }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -290,7 +328,13 @@ function TemplateDetail({ template }: { template: AgentTemplate }) {
|
||||
|
||||
<TemplateMetaGroup
|
||||
items={[
|
||||
{ label: "Type", value: "Built-in template" },
|
||||
{
|
||||
label: "Type",
|
||||
value:
|
||||
template.source === "saved"
|
||||
? "Saved template"
|
||||
: "Built-in template",
|
||||
},
|
||||
{
|
||||
label: "Preferred model",
|
||||
value: template.model ?? "Use app default",
|
||||
|
||||
@@ -43,6 +43,7 @@ type UnifiedAgentsSectionProps = {
|
||||
onDuplicateAgent: (agent: ManagedAgent) => 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}
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AgentDraft | null>(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,
|
||||
|
||||
@@ -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<AgentTemplate[]> {
|
||||
return invokeTauri<AgentTemplate[]>("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<AgentTemplate> {
|
||||
return invokeTauri<AgentTemplate>("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.
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<MockAgentTemplate[]> {
|
||||
return [
|
||||
const builtins: MockAgentTemplate[] = [
|
||||
{
|
||||
id: "builtin:fizz",
|
||||
displayName: "Fizz",
|
||||
@@ -5896,7 +5898,9 @@ async function handleListAgentTemplates(): Promise<MockAgentTemplate[]> {
|
||||
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<MockAgentTemplate[]> {
|
||||
"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<MockAgentTemplate[]> {
|
||||
"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<MockAgentTemplate> {
|
||||
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<RawTeam[]> {
|
||||
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":
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user