feat(desktop): agents own their config — team agent members, flattening migration, built-in templates (Tauri)

Backend half of removing personas from the UI. User-created agents no
longer have a backing persona record; persona records are quarantined to
team packs, which genuinely need them for directory install/sync and
writeback.

- types.rs, team_events.rs: add agent_pubkeys to TeamRecord, the
  create/update team inputs, and the kind:30176 payload. The field is
  additive — events published by older clients parse with empty agent
  membership (round-trip and tolerance tests included)
- commands/teams.rs: normalize and validate agent pubkeys on team
  create/update; team export resolves agent members into the interchange
  member shape alongside persona members; pack install/sync materialize
  pack personas as stopped agents so they appear in the grid immediately
- commands/personas/mod.rs: inbound kind:30176 reconcile applies
  agent_pubkeys from remote events
- managed_agents/flatten.rs (new): idempotent boot migration —
  materialize a stopped agent for every active persona without one,
  rewrite non-pack team persona members to agent pubkeys, pin persona
  config onto linked agents and clear persona_id, then deactivate the
  flattened non-pack personas (records stay on disk/relay for rollback).
  flatten_tests.rs covers materialization, team rewrite, pack
  quarantine, and re-run idempotency
- managed_agents/personas.rs: stop seeding built-in personas as records;
  the same data is now a static AgentTemplate list, and merge_personas
  only demotes stale is_builtin flags
- commands/agent_templates.rs (new): list_agent_templates and
  export_agent_to_json — agent-record export to the .persona.json
  interchange format (env vars excluded, matching persona export)
- commands/agents.rs: create_managed_agent accepts an explicit provider
  so templates and duplicates can pin the LLM provider at create time
- lib.rs: run the flatten migration post-identity, before event sync, so
  rewritten teams/personas are what get reconciled and published;
  register the new template commands
- team_repair.rs, personas/writeback.rs, inbound_tests.rs,
  personas/tests.rs: fixtures and merge-semantics tests updated for the
  new fields and the removal of built-in seeding; the shared test UUID
  constant avoids a digit run the secret scanner reads as a card number

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-02 22:39:27 -07:00
parent b38a412345
commit ce88863d78
17 changed files with 1081 additions and 315 deletions
@@ -0,0 +1,73 @@
//! Agent templates and agent-record export.
//!
//! Templates are static starter data for the Create Agent wizard — selecting
//! one prefills the create form; no persona record is involved. Export maps a
//! managed agent's pinned config onto the shareable `.persona.json` card
//! interchange format.
use tauri::{AppHandle, State};
use crate::{
app_state::AppState,
managed_agents::{load_managed_agents, load_personas},
};
/// Built-in agent templates for the Create Agent wizard. Static data — no
/// store access, no lock.
#[tauri::command]
pub fn list_agent_templates() -> Vec<crate::managed_agents::AgentTemplate> {
crate::managed_agents::builtin_agent_templates()
}
/// Export a managed agent's pinned config as a shareable `.persona.json`
/// card (the interchange format). `env_vars` are deliberately excluded —
/// cards are shareable artifacts and must never carry credentials.
#[tauri::command]
pub async fn export_agent_to_json(
pubkey: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
// Load the record under lock, then drop the lock before the dialog.
let (name, system_prompt, avatar_url, runtime, model, provider) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
let personas = load_personas(&app).unwrap_or_default();
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let runtime =
crate::managed_agents::known_acp_runtime(&effective_command).map(|r| r.id.to_string());
(
record.name.clone(),
record.system_prompt.clone().unwrap_or_default(),
record.avatar_url.clone(),
runtime,
record.model.clone(),
record.provider.clone(),
)
};
let json_bytes = crate::managed_agents::encode_persona_json(
&name,
&system_prompt,
avatar_url.as_deref(),
runtime.as_deref(),
model.as_deref(),
provider.as_deref(),
&[],
)?;
let slug = crate::util::slugify(&name, "agent", 50);
let filename = format!("{slug}.persona.json");
super::export_util::save_json_with_dialog(&app, &filename, &json_bytes).await
}
+8 -1
View File
@@ -795,7 +795,14 @@ pub async fn create_managed_agent(
.filter(|value| !value.is_empty())
.map(str::to_string)
}),
provider: snapshot_provider,
provider: snapshot_provider.or_else(|| {
input
.provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}),
persona_source_version: snapshot_source_version,
mcp_toolsets: input
.mcp_toolsets
+2
View File
@@ -3,6 +3,7 @@ mod agent_discovery;
mod agent_models;
mod agent_providers;
mod agent_settings;
mod agent_templates;
mod agents;
mod canvas;
mod channel_templates;
@@ -37,6 +38,7 @@ pub use agent_discovery::*;
pub use agent_models::*;
pub use agent_providers::*;
pub use agent_settings::*;
pub use agent_templates::*;
pub use agents::*;
pub use canvas::*;
pub use channel_templates::*;
@@ -4,7 +4,7 @@
use super::*;
use std::collections::BTreeMap;
const UUID: &str = "11111111-2222-3333-4444-555555555555";
const UUID: &str = "11111111-2222-3333-abcd-555555555555";
/// A local in-app persona: `source_team_persona_slug` is None, so its d-tag
/// IS its UUID id. Carries env_vars + source_team that must survive a patch.
@@ -295,6 +295,7 @@ fn local_team() -> TeamRecord {
name: "Local Team".to_string(),
description: Some("local desc".to_string()),
persona_ids: vec!["p-local".to_string()],
agent_pubkeys: vec!["local-agent-pk".to_string()],
is_builtin: false,
source_dir: Some(std::path::PathBuf::from("/local/team/dir")),
is_symlink: true,
@@ -310,6 +311,7 @@ fn team_content(name: &str) -> TeamEventContent {
name: name.to_string(),
description: Some("remote desc".to_string()),
persona_ids: vec!["p-remote-1".to_string(), "p-remote-2".to_string()],
agent_pubkeys: vec!["remote-agent-pk".to_string()],
}
}
@@ -676,12 +676,14 @@ fn apply_inbound_team(teams: &mut Vec<TeamRecord>, d_tag: String, inbound: TeamE
local.name = inbound.name;
local.description = inbound.description;
local.persona_ids = inbound.persona_ids;
local.agent_pubkeys = inbound.agent_pubkeys;
}
None => teams.push(TeamRecord {
id: d_tag,
name: inbound.name,
description: inbound.description,
persona_ids: inbound.persona_ids,
agent_pubkeys: inbound.agent_pubkeys,
is_builtin: false,
source_dir: None,
is_symlink: false,
@@ -773,8 +773,9 @@ You are Paul.
name: id.to_string(),
description: None,
persona_ids: vec![],
agent_pubkeys: vec![],
is_builtin: false,
source_dir: source_dir.map(|s| std::path::PathBuf::from(s)),
source_dir: source_dir.map(std::path::PathBuf::from),
is_symlink: false,
symlink_target: None,
version: None,
+38 -4
View File
@@ -28,6 +28,14 @@ fn trim_optional(value: Option<String>) -> Option<String> {
})
}
/// Validate and normalize team agent members: each entry must be a 64-char hex
/// pubkey (lowercased, deduped). Existence is NOT checked — a team synced from
/// another device may reference agents that only exist there.
fn normalize_team_agent_pubkeys(input: &[String]) -> Result<Vec<String>, String> {
crate::managed_agents::validate_respond_to_allowlist(input)
.map_err(|e| e.replace("respond-to allowlist", "team members"))
}
/// Retain a freshly authored team event in the local store, flagged for relay
/// sync. Called inside a command's `managed_agents_store_lock`-held body after
/// `save_teams`; the background flush loop publishes it out-of-band.
@@ -160,6 +168,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result<Tea
let description = trim_optional(input.description);
let now = now_iso();
let agent_pubkeys = normalize_team_agent_pubkeys(&input.agent_pubkeys)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -172,6 +181,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result<Tea
name,
description,
persona_ids: input.persona_ids,
agent_pubkeys,
is_builtin: false,
source_dir: None,
is_symlink: false,
@@ -197,6 +207,7 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<Tea
let state = app.state::<AppState>();
let name = trim_required(&input.name, "Team name")?;
let description = trim_optional(input.description);
let agent_pubkeys = normalize_team_agent_pubkeys(&input.agent_pubkeys)?;
let _store_guard = state
.managed_agents_store_lock
@@ -213,6 +224,7 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<Tea
team.name = name;
team.description = description;
team.persona_ids = input.persona_ids;
team.agent_pubkeys = agent_pubkeys;
team.updated_at = now_iso();
let updated = team.clone();
@@ -253,6 +265,25 @@ 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,
@@ -271,6 +302,7 @@ pub async fn install_team_from_directory(
return Err(format!("team path is not a directory: {path}"));
}
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
materialize_pack_agents_locked(&app, &state);
try_regenerate_nest(&app);
Ok(result)
})
@@ -288,6 +320,7 @@ pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result<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)
})
@@ -310,8 +343,8 @@ pub async fn export_team_to_json(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
// Load team and personas under lock, then drop lock before dialog.
let (team, personas) = {
// Load team, personas, and agents under lock, then drop lock before dialog.
let (team, personas, agents) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -322,10 +355,11 @@ pub async fn export_team_to_json(
.find(|t| t.id == id)
.ok_or_else(|| format!("team {id} not found"))?;
let personas = load_personas(&app)?;
(team, personas)
let agents = crate::managed_agents::load_managed_agents(&app)?;
(team, personas, agents)
};
let json_bytes = encode_team_json(&team, &personas)?;
let json_bytes = encode_team_json(&team, &personas, &agents)?;
let slug = crate::util::slugify(&team.name, "team", 50);
let filename = format!("{slug}.team.json");
+25
View File
@@ -239,6 +239,29 @@ pub fn run() {
.lock()
.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
@@ -545,6 +568,8 @@ pub fn run() {
delete_persona,
set_persona_active,
reconcile_inbound_persona_event,
list_agent_templates,
export_agent_to_json,
list_channel_templates,
create_channel_template,
update_channel_template,
@@ -0,0 +1,398 @@
//! One-time flattening migration: agents own their config outright.
//!
//! Personas used to be the user-facing template primitive — the Agents grid
//! rendered one card per active persona, and agents linked back via
//! `persona_id`. The agents-first model quarantines persona records to team
//! packs (directory-backed teams genuinely need them for pack sync and
//! writeback); every other agent carries its full config on its own record.
//!
//! The migration, run post-identity on every boot (idempotent, cheap no-op
//! once flattened):
//!
//! 1. **Materialize** a stopped managed agent for every `is_active` persona
//! with no linked agent, so persona-only cards don't visibly disappear.
//! Pack personas keep their `persona_id` link; others are created flat.
//! 2. **Rewrite teams**: non-pack persona members become agent-pubkey members
//! (`TeamRecord.agent_pubkeys`); `persona_ids` remains only for pack teams.
//! 3. **Flatten agents**: for every agent linked to a non-pack persona, pin
//! the live persona config onto the record (mirroring what the next spawn
//! re-snapshot would have done) and clear `persona_id`.
//! 4. **Deactivate** flattened non-pack personas. Records stay on disk and on
//! the relay for rollback; the UI never renders them.
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, Manager};
use crate::{
app_state::AppState,
managed_agents::{
effective_agent_command, known_acp_runtime, load_managed_agents, load_personas, load_teams,
managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args,
persona_events::persona_snapshot, save_managed_agents, save_personas, save_teams,
ManagedAgentRecord, PersonaRecord, TeamRecord, DEFAULT_ACP_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
util::now_iso,
};
#[derive(Debug, Default, Clone, Copy)]
pub struct FlattenReport {
pub agents_materialized: usize,
pub agents_flattened: usize,
pub teams_rewritten: usize,
pub personas_deactivated: usize,
}
impl FlattenReport {
pub fn is_noop(&self) -> bool {
self.agents_materialized == 0
&& self.agents_flattened == 0
&& self.teams_rewritten == 0
&& self.personas_deactivated == 0
}
}
fn is_pack_persona(persona: &PersonaRecord) -> bool {
persona.source_team.is_some()
}
/// Compute the NIP-OA auth tag for a freshly generated agent keypair.
fn compute_agent_auth_tag(owner_keys: &Keys, agent_keys: &Keys) -> Result<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;
@@ -0,0 +1,292 @@
use std::collections::BTreeMap;
use nostr::Keys;
use super::flatten_in_place;
use crate::managed_agents::{ManagedAgentRecord, PersonaRecord, TeamRecord};
fn persona(id: &str, name: &str) -> PersonaRecord {
PersonaRecord {
id: id.to_string(),
display_name: name.to_string(),
avatar_url: Some(format!("https://example.com/{id}.png")),
system_prompt: format!("You are {name}."),
runtime: Some("goose".to_string()),
model: Some("claude-test".to_string()),
provider: Some("anthropic".to_string()),
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: BTreeMap::from([("PERSONA_KEY".to_string(), "persona-value".to_string())]),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
fn pack_persona(id: &str, name: &str) -> PersonaRecord {
PersonaRecord {
source_team: Some("com.example.pack".to_string()),
source_team_persona_slug: Some("scout".to_string()),
..persona(id, name)
}
}
fn agent(pubkey: &str, name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: pubkey.to_string(),
name: name.to_string(),
persona_id: persona_id.map(str::to_string),
private_key_nsec: String::new(),
auth_tag: None,
relay_url: String::new(),
avatar_url: None,
acp_command: "buzz-acp".to_string(),
agent_command: "goose".to_string(),
agent_command_override: None,
agent_args: vec!["acp".to_string()],
mcp_command: String::new(),
turn_timeout_seconds: 320,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: None,
model: None,
provider: None,
persona_source_version: Some("hash".to_string()),
mcp_toolsets: None,
start_on_app_launch: false,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
provider_binary_path: None,
persona_team_dir: None,
persona_name_in_team: None,
env_vars: BTreeMap::from([("AGENT_KEY".to_string(), "agent-value".to_string())]),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
respond_to: Default::default(),
respond_to_allowlist: vec![],
relay_mesh: None,
}
}
fn team(id: &str, persona_ids: &[&str]) -> TeamRecord {
TeamRecord {
id: id.to_string(),
name: id.to_string(),
description: None,
persona_ids: persona_ids.iter().map(|s| s.to_string()).collect(),
agent_pubkeys: Vec::new(),
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
fn owner_keys() -> Keys {
Keys::generate()
}
#[test]
fn flattens_persona_backed_agent_and_pins_config() {
let mut personas = vec![persona("p1", "Honey")];
let mut agents = vec![agent("agent-pk", "Honey", Some("p1"))];
let mut teams = vec![];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.agents_flattened, 1);
assert_eq!(report.agents_materialized, 0);
let flat = &agents[0];
assert_eq!(flat.persona_id, None);
assert_eq!(flat.persona_source_version, None);
assert_eq!(flat.system_prompt.as_deref(), Some("You are Honey."));
assert_eq!(flat.model.as_deref(), Some("claude-test"));
assert_eq!(flat.provider.as_deref(), Some("anthropic"));
// Persona env layered under agent env — both keys survive.
assert_eq!(
flat.env_vars.get("PERSONA_KEY").map(String::as_str),
Some("persona-value")
);
assert_eq!(
flat.env_vars.get("AGENT_KEY").map(String::as_str),
Some("agent-value")
);
// Avatar backfilled from the persona.
assert_eq!(
flat.avatar_url.as_deref(),
Some("https://example.com/p1.png")
);
// Harness pinned so losing the persona link doesn't fall back to default.
assert!(flat.agent_command_override.is_some());
// Persona deactivated.
assert_eq!(report.personas_deactivated, 1);
assert!(!personas[0].is_active);
}
#[test]
fn agent_env_overrides_win_over_persona_env() {
let mut personas = vec![persona("p1", "Honey")];
personas[0]
.env_vars
.insert("SHARED".to_string(), "persona".to_string());
let mut agents = vec![agent("agent-pk", "Honey", Some("p1"))];
agents[0]
.env_vars
.insert("SHARED".to_string(), "agent".to_string());
let mut teams = vec![];
flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(
agents[0].env_vars.get("SHARED").map(String::as_str),
Some("agent")
);
}
#[test]
fn materializes_stopped_agent_for_active_persona_without_agent() {
let mut personas = vec![persona("p1", "Honey")];
let mut agents = vec![];
let mut teams = vec![];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.agents_materialized, 1);
assert_eq!(agents.len(), 1);
let created = &agents[0];
assert_eq!(created.name, "Honey");
// Materialized flat: persona link cleared during flattening.
assert_eq!(created.persona_id, None);
assert_eq!(created.system_prompt.as_deref(), Some("You are Honey."));
assert!(!created.start_on_app_launch);
assert!(created.auth_tag.is_some());
assert!(!created.private_key_nsec.is_empty());
assert_eq!(created.backend, crate::managed_agents::BackendKind::Local);
// Persona ends up deactivated.
assert!(!personas[0].is_active);
}
#[test]
fn inactive_persona_without_agent_is_not_materialized() {
let mut personas = vec![PersonaRecord {
is_active: false,
..persona("p1", "Dormant")
}];
let mut agents = vec![];
let mut teams = vec![];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.agents_materialized, 0);
assert!(agents.is_empty());
}
#[test]
fn pack_personas_and_their_agents_are_untouched() {
let mut personas = vec![pack_persona("pack-p", "Scout")];
let mut agents = vec![agent("agent-pk", "Scout", Some("pack-p"))];
let mut teams = vec![];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.agents_flattened, 0);
assert_eq!(report.personas_deactivated, 0);
assert_eq!(agents[0].persona_id.as_deref(), Some("pack-p"));
assert!(personas[0].is_active);
}
#[test]
fn pack_persona_without_agent_materializes_with_persona_link() {
let mut personas = vec![pack_persona("pack-p", "Scout")];
let mut agents = vec![];
let mut teams = vec![];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.agents_materialized, 1);
assert_eq!(agents[0].persona_id.as_deref(), Some("pack-p"));
assert!(agents[0].persona_source_version.is_some());
}
#[test]
fn team_persona_members_migrate_to_agent_pubkeys() {
let mut personas = vec![persona("p1", "Honey"), pack_persona("pack-p", "Scout")];
let mut agents = vec![
agent("honey-pk", "Honey", Some("p1")),
agent("scout-pk", "Scout", Some("pack-p")),
];
let mut teams = vec![team("t1", &["p1", "pack-p"])];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.teams_rewritten, 1);
// Non-pack member became an agent pubkey; pack member stayed a persona id.
assert_eq!(teams[0].agent_pubkeys, vec!["honey-pk".to_string()]);
assert_eq!(teams[0].persona_ids, vec!["pack-p".to_string()]);
}
#[test]
fn team_member_without_agent_is_materialized_then_migrated() {
let mut personas = vec![persona("p1", "Honey")];
let mut agents = vec![];
let mut teams = vec![team("t1", &["p1"])];
let report = flatten_in_place(&mut personas, &mut agents, &mut teams, &owner_keys(), None);
assert_eq!(report.agents_materialized, 1);
assert_eq!(teams[0].persona_ids, Vec::<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,6 +5,7 @@ mod backend;
pub(crate) mod config_bridge;
mod discovery;
mod env_vars;
mod flatten;
mod nest;
mod persona_avatars;
mod persona_card;
@@ -27,6 +28,7 @@ mod types;
pub use backend::*;
pub use discovery::*;
pub use env_vars::*;
pub use flatten::*;
pub use nest::*;
pub use persona_card::*;
pub use personas::*;
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
use super::{
ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas,
migrate_retired_personas, validate_persona_activation_change, validate_persona_deletion,
BUILT_IN_PERSONAS, RETIRED_PERSONAS,
builtin_agent_templates, ensure_persona_ids_are_active, ensure_persona_is_active,
merge_personas, migrate_retired_personas, validate_persona_activation_change,
validate_persona_deletion, RETIRED_PERSONAS,
};
use crate::managed_agents::validate_team_id;
use crate::managed_agents::PersonaRecord;
@@ -27,21 +27,20 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
}
#[test]
fn merge_personas_adds_missing_built_ins() {
fn merge_personas_seeds_nothing() {
let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z");
assert!(changed);
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
assert!(records
assert!(!changed);
assert!(records.is_empty());
}
#[test]
fn builtin_agent_templates_expose_starter_data() {
let templates = builtin_agent_templates();
let display_names: Vec<&str> = templates
.iter()
.any(|record| record.id == "builtin:fizz" && record.runtime.as_deref() == Some("goose")));
assert!(records
.iter()
.any(|record| record.id == "builtin:product-strategist" && !record.is_active));
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())
.map(|template| template.display_name.as_str())
.collect();
assert_eq!(
display_names,
@@ -55,12 +54,13 @@ fn merge_personas_adds_missing_built_ins() {
"Experiment Designer"
]
);
let active_ids: Vec<&str> = records
let fizz = templates
.iter()
.filter(|record| record.is_active)
.map(|record| record.id.as_str())
.collect();
assert_eq!(active_ids, vec!["builtin:fizz"]);
.find(|template| template.id == "builtin:fizz")
.expect("fizz template should exist");
assert_eq!(fizz.runtime.as_deref(), Some("goose"));
assert!(!fizz.system_prompt.is_empty());
assert!(fizz.avatar_url.is_some());
}
#[test]
@@ -68,94 +68,33 @@ fn merge_personas_preserves_custom_records() {
let custom = custom_persona("custom:test", "Custom");
let (records, changed) = merge_personas(vec![custom.clone()], "2026-03-19T00:00:00Z");
assert!(changed);
assert!(!changed);
assert!(records.iter().any(|record| record.id == custom.id));
}
#[test]
fn merge_personas_restores_builtin_defaults() {
let mut edited_builtin = custom_persona("builtin:fizz", "My Fizz");
edited_builtin.is_builtin = true;
edited_builtin.is_active = true;
let original_created_at = edited_builtin.created_at.clone();
let original_updated_at = edited_builtin.updated_at.clone();
fn merge_personas_demotes_legacy_builtin_records() {
let mut legacy_builtin = custom_persona("builtin:fizz", "Fizz");
legacy_builtin.is_builtin = true;
legacy_builtin.is_active = true;
let original_created_at = legacy_builtin.created_at.clone();
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
let (records, changed) = merge_personas(vec![legacy_builtin], "2026-03-19T00:00:00Z");
assert!(changed);
let fizz = records
.iter()
.find(|record| record.id == "builtin:fizz")
.expect("fizz built-in should exist");
let canonical = BUILT_IN_PERSONAS
.iter()
.find(|persona| persona.id == "builtin:fizz")
.expect("fizz built-in definition should exist");
assert_eq!(fizz.display_name, canonical.display_name);
assert_eq!(fizz.avatar_url.as_deref(), canonical.avatar_url,);
.expect("legacy fizz record should be retained as a custom persona");
assert!(!fizz.is_builtin);
// Local activation state survives — the flatten migration handles it.
assert!(fizz.is_active);
assert_eq!(fizz.created_at, original_created_at);
assert_eq!(fizz.updated_at, original_updated_at);
assert!(fizz.is_active);
assert_eq!(fizz.updated_at, "2026-03-19T00:00:00Z");
}
#[test]
fn merge_personas_restores_builtin_env_vars() {
// A hand-edited built-in record with stray env vars should be reset to
// the canonical (empty) env on merge. Built-ins are intended immutable —
// if a user wants per-persona credentials, they create or duplicate to a
// custom persona.
let mut tampered = custom_persona("builtin:fizz", "Fizz");
tampered.is_builtin = true;
tampered.avatar_url = None;
tampered.is_active = true;
tampered.env_vars =
std::collections::BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "leaked".to_string())]);
let (records, changed) = merge_personas(vec![tampered], "2026-03-19T00:00:00Z");
assert!(changed);
let fizz = records
.iter()
.find(|record| record.id == "builtin:fizz")
.expect("fizz built-in should exist");
// Built-in persona definitions have no `env_vars` field — they are
// always empty. The merge reset should clear the tampered key entirely.
assert!(
fizz.env_vars.is_empty(),
"expected empty, got {:?}",
fizz.env_vars
);
}
#[test]
fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() {
let mut fizz = custom_persona("builtin:fizz", "Fizz");
fizz.is_builtin = true;
fizz.avatar_url = None;
fizz.is_active = true;
fizz.name_pool = vec!["Definitely Not Fizz".to_string()];
let (records, changed) = merge_personas(vec![fizz], "2026-03-19T00:00:00Z");
assert!(changed);
let fizz = records
.iter()
.find(|record| record.id == "builtin:fizz")
.expect("fizz built-in should exist");
let expected_name_pool = BUILT_IN_PERSONAS
.iter()
.find(|persona| persona.id == "builtin:fizz")
.expect("fizz built-in definition should exist")
.name_pool
.iter()
.map(|name| (*name).to_string())
.collect::<Vec<_>>();
assert_eq!(fizz.name_pool, expected_name_pool);
assert!(fizz.is_active);
}
#[test]
fn merge_personas_adds_fizz_and_retires_old_builtins_for_existing_store() {
fn merge_personas_retires_old_builtins_for_existing_store() {
let mut legacy_builtins = vec![custom_persona("builtin:solo", "Solo")];
for persona in &mut legacy_builtins {
persona.is_builtin = true;
@@ -165,13 +104,6 @@ fn merge_personas_adds_fizz_and_retires_old_builtins_for_existing_store() {
let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z");
assert!(changed);
let fizz = records
.iter()
.find(|record| record.id == "builtin:fizz")
.expect("fizz built-in should exist");
assert!(fizz.is_builtin);
assert!(fizz.is_active);
let solo = records
.iter()
.find(|record| record.id == "builtin:solo")
@@ -221,10 +153,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() {
let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err();
assert_eq!(
err,
"Fizz is not in My Agents. Choose it from Agent Catalog first."
);
assert_eq!(err, "Fizz is not available for new agents.");
}
#[test]
@@ -26,6 +26,10 @@ pub struct TeamEventContent {
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub persona_ids: Vec<String>,
/// Managed-agent members by pubkey. Additive field — events published by
/// older clients simply omit it and parse to an empty list.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agent_pubkeys: Vec<String>,
}
/// Project a `TeamRecord` onto the content fields published in team events.
@@ -36,6 +40,7 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent {
name: record.name.clone(),
description: record.description.clone(),
persona_ids: record.persona_ids.clone(),
agent_pubkeys: record.agent_pubkeys.clone(),
}
}
@@ -87,6 +92,7 @@ mod tests {
name: "Test Team".to_string(),
description: Some("A test team".to_string()),
persona_ids: vec!["p1".to_string(), "p2".to_string()],
agent_pubkeys: vec!["a".repeat(64)],
is_builtin: false,
source_dir: Some(PathBuf::from("/local/only/path")),
is_symlink: true,
@@ -143,8 +149,21 @@ mod tests {
fn content_round_trips() {
let event_content = team_event_content(&sample_team());
let json = serde_json::to_string(&event_content).unwrap();
assert!(json.contains("\"agent_pubkeys\""));
let restored: TeamEventContent = serde_json::from_str(&json).unwrap();
assert_eq!(restored, event_content);
assert_eq!(restored.agent_pubkeys, vec!["a".repeat(64)]);
}
#[test]
fn content_from_old_clients_without_agent_pubkeys_parses_empty() {
// agent_pubkeys is additive: events published before the field existed
// must still parse, with membership defaulting to no agent members.
let legacy = r#"{"name":"Old Team","persona_ids":["p1"]}"#;
let restored: TeamEventContent = serde_json::from_str(legacy).unwrap();
assert_eq!(restored.name, "Old Team");
assert_eq!(restored.persona_ids, vec!["p1"]);
assert!(restored.agent_pubkeys.is_empty());
}
#[test]
@@ -260,6 +260,7 @@ mod tests {
name: id.to_string(),
description: None,
persona_ids: Vec::new(),
agent_pubkeys: Vec::new(),
is_builtin: false,
source_dir: None,
is_symlink: false,
+137 -125
View File
@@ -38,69 +38,16 @@ fn sort_teams(records: &mut [TeamRecord]) {
});
}
struct BuiltInTeam {
id: &'static str,
name: &'static str,
description: Option<&'static str>,
persona_ids: &'static [&'static str],
}
const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam {
id: "builtin-team:fizz",
name: "Fizz",
description: Some("Fizz works carefully and collaboratively."),
persona_ids: &["builtin:fizz"],
}];
fn built_in_team_records(now: &str) -> Vec<TeamRecord> {
BUILT_IN_TEAMS
.iter()
.map(|team| TeamRecord {
id: team.id.to_string(),
name: team.name.to_string(),
description: team.description.map(|s| s.to_string()),
persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(),
is_builtin: true,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.to_string(),
updated_at: now.to_string(),
})
.collect()
}
fn built_in_team_order(id: &str) -> Option<usize> {
BUILT_IN_TEAMS.iter().position(|team| team.id == id)
}
/// Add missing built-in teams, demote stale built-ins, and preserve any
/// user customizations to existing built-in teams (name, description,
/// persona membership). Returns the merged list and whether the store
/// changed.
/// Demote stale built-in teams. There are no seeded built-in teams anymore
/// (the former built-in "Fizz" team was a persona grouping; agents are the
/// user-facing concept now) — any record still flagged `is_builtin` becomes a
/// user-owned custom team they can edit or delete. Returns the merged list
/// and whether the store changed.
fn merge_teams(mut stored: Vec<TeamRecord>, now: &str) -> (Vec<TeamRecord>, bool) {
let mut changed = false;
for built_in in built_in_team_records(now) {
if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) {
if !existing.is_builtin {
existing.is_builtin = true;
existing.updated_at = now.to_string();
changed = true;
}
} else {
stored.push(built_in);
changed = true;
}
}
// Demote any stored team flagged as built-in whose id is no longer in
// BUILT_IN_TEAMS (e.g. a built-in that has been retired). The record
// stays so existing references keep working; it becomes a user-owned
// custom team they can edit or delete.
for record in stored.iter_mut() {
if record.is_builtin && built_in_team_order(&record.id).is_none() {
if record.is_builtin {
record.is_builtin = false;
record.updated_at = now.to_string();
changed = true;
@@ -325,6 +272,7 @@ pub fn import_team_from_directory(
Some(resolved.description)
},
persona_ids,
agent_pubkeys: Vec::new(),
is_builtin: false,
source_dir: Some(dest),
is_symlink: use_symlink,
@@ -607,33 +555,53 @@ pub fn sync_team_from_dir(
}
/// Encode a team as a JSON blob for export. The format includes the team's
/// name, description, and the full persona data for each member (so the
/// import side can recreate personas that don't exist locally).
pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result<Vec<u8>, String> {
let mut missing_persona_ids = Vec::new();
let mut resolved_personas = Vec::with_capacity(team.persona_ids.len());
/// name, description, and the full member data (so the import side can
/// recreate agents that don't exist locally). Persona-backed members and
/// agent members serialize into the same `personas` array — the interchange
/// member shape — for backward compatibility with older imports.
pub fn encode_team_json(
team: &TeamRecord,
personas: &[PersonaRecord],
agents: &[crate::managed_agents::ManagedAgentRecord],
) -> Result<Vec<u8>, String> {
let mut missing_members = Vec::new();
let mut resolved_members =
Vec::with_capacity(team.persona_ids.len() + team.agent_pubkeys.len());
for persona_id in &team.persona_ids {
let Some(persona) = personas
.iter()
.find(|candidate| candidate.id == *persona_id)
else {
missing_persona_ids.push(persona_id.clone());
missing_members.push(persona_id.clone());
continue;
};
resolved_personas.push(serde_json::json!({
resolved_members.push(serde_json::json!({
"displayName": persona.display_name,
"systemPrompt": persona.system_prompt,
"avatarUrl": persona.avatar_url,
}));
}
if !missing_persona_ids.is_empty() {
for pubkey in &team.agent_pubkeys {
let Some(agent) = agents.iter().find(|candidate| candidate.pubkey == *pubkey) else {
missing_members.push(pubkey.clone());
continue;
};
resolved_members.push(serde_json::json!({
"displayName": agent.name,
"systemPrompt": agent.system_prompt.clone().unwrap_or_default(),
"avatarUrl": agent.avatar_url,
}));
}
if !missing_members.is_empty() {
return Err(format!(
"Team {} references missing personas: {}. Repair the team before exporting.",
"Team {} references missing members: {}. Repair the team before exporting.",
team.name,
missing_persona_ids.join(", ")
missing_members.join(", ")
));
}
@@ -642,7 +610,7 @@ pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result
"type": "team",
"name": team.name,
"description": team.description,
"personas": resolved_personas,
"personas": resolved_members,
});
serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize team JSON: {e}"))
@@ -690,6 +658,8 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
.and_then(|v| v.as_str())?
.trim()
.to_string();
// An empty prompt is valid for agent members — core memory
// is injected at runtime — but the field must be present.
let system_prompt = p
.get("systemPrompt")
.and_then(|v| v.as_str())?
@@ -700,7 +670,7 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if display_name.is_empty() || system_prompt.is_empty() {
if display_name.is_empty() {
return None;
}
Some(TeamPersonaPreview {
@@ -724,7 +694,6 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
mod tests {
use super::{
encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion,
BUILT_IN_TEAMS,
};
use crate::managed_agents::{PersonaRecord, TeamRecord};
@@ -734,6 +703,7 @@ mod tests {
name: name.to_string(),
description: None,
persona_ids: Vec::new(),
agent_pubkeys: Vec::new(),
is_builtin: false,
source_dir: None,
is_symlink: false,
@@ -802,7 +772,7 @@ mod tests {
persona("p2", "Bob", "You are Bob"),
];
let bytes = encode_team_json(&t, &personas).unwrap();
let bytes = encode_team_json(&t, &personas, &[]).unwrap();
let parsed = parse_team_json(&bytes).unwrap();
assert_eq!(parsed.name, "My Team");
@@ -822,14 +792,88 @@ mod tests {
};
let personas = vec![persona("p1", "Alice", "prompt")];
let err = encode_team_json(&t, &personas).unwrap_err();
let err = encode_team_json(&t, &personas, &[]).unwrap_err();
assert_eq!(
err,
"Team Team references missing personas: missing. Repair the team before exporting."
"Team Team references missing members: missing. Repair the team before exporting."
);
}
fn agent_record(
pubkey: &str,
name: &str,
prompt: Option<&str>,
) -> crate::managed_agents::ManagedAgentRecord {
crate::managed_agents::ManagedAgentRecord {
pubkey: pubkey.to_string(),
name: name.to_string(),
persona_id: None,
private_key_nsec: String::new(),
auth_tag: None,
relay_url: String::new(),
avatar_url: Some("https://example.com/a.png".to_string()),
acp_command: "buzz-acp".to_string(),
agent_command: "buzz-agent".to_string(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 320,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: prompt.map(str::to_string),
model: None,
provider: None,
persona_source_version: None,
mcp_toolsets: None,
start_on_app_launch: false,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
provider_binary_path: None,
persona_team_dir: None,
persona_name_in_team: None,
env_vars: std::collections::BTreeMap::new(),
created_at: String::new(),
updated_at: String::new(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
respond_to: Default::default(),
respond_to_allowlist: vec![],
relay_mesh: None,
}
}
#[test]
fn encode_includes_agent_members() {
let t = TeamRecord {
agent_pubkeys: vec!["a".repeat(64)],
..team("t1", "Team")
};
let agents = vec![agent_record(&"a".repeat(64), "Honey", Some("Be sweet"))];
let bytes = encode_team_json(&t, &[], &agents).unwrap();
let parsed = parse_team_json(&bytes).unwrap();
assert_eq!(parsed.personas.len(), 1);
assert_eq!(parsed.personas[0].display_name, "Honey");
assert_eq!(parsed.personas[0].system_prompt, "Be sweet");
}
#[test]
fn encode_errors_for_missing_agent_members() {
let t = TeamRecord {
agent_pubkeys: vec!["b".repeat(64)],
..team("t1", "Team")
};
let err = encode_team_json(&t, &[], &[]).unwrap_err();
assert!(err.contains("missing members"), "{err}");
}
#[test]
fn parse_team_json_invalid_version() {
let json = serde_json::json!({
@@ -898,73 +942,41 @@ mod tests {
}
#[test]
fn merge_teams_adds_missing_built_ins() {
fn merge_teams_seeds_nothing() {
let (records, changed) = merge_teams(Vec::new(), "2026-05-07T00:00:00Z");
assert!(changed);
assert_eq!(records.len(), BUILT_IN_TEAMS.len());
assert!(records.iter().all(|record| record.is_builtin));
let names: Vec<&str> = records.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["Fizz"]);
assert!(!changed);
assert!(records.is_empty());
}
#[test]
fn merge_teams_preserves_user_customizations_to_builtin() {
let mut customized = team("builtin-team:fizz", "Fizz (mine)");
customized.is_builtin = true;
customized.persona_ids = vec!["builtin:fizz".to_string()];
fn merge_teams_demotes_legacy_built_ins() {
let mut legacy = team("builtin-team:fizz", "Fizz");
legacy.is_builtin = true;
legacy.persona_ids = vec!["builtin:fizz".to_string()];
let (records, _changed) = merge_teams(vec![customized], "2026-05-07T00:00:00Z");
let (records, changed) = merge_teams(vec![legacy], "2026-05-07T00:00:00Z");
assert!(changed);
let fizz = records
.iter()
.find(|t| t.id == "builtin-team:fizz")
.expect("Fizz built-in should exist");
assert_eq!(fizz.name, "Fizz (mine)");
.expect("legacy built-in should be retained as a custom team");
assert!(!fizz.is_builtin);
assert_eq!(fizz.updated_at, "2026-05-07T00:00:00Z");
// Membership is preserved; the flatten migration rewrites it to
// agent pubkeys separately.
assert_eq!(fizz.persona_ids, vec!["builtin:fizz".to_string()]);
assert!(fizz.is_builtin);
}
#[test]
fn merge_teams_preserves_unrelated_user_teams() {
fn merge_teams_preserves_user_teams_untouched() {
let user_team = team("user-uuid", "My Team");
let (records, _changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z");
let (records, changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z");
assert!(!changed);
assert!(records.iter().any(|t| t.id == "user-uuid"));
assert!(records.iter().any(|t| t.id == "builtin-team:fizz"));
}
#[test]
fn merge_teams_demotes_retired_built_ins() {
let mut retired = team("builtin-team:legacy", "Legacy");
retired.is_builtin = true;
let (records, changed) = merge_teams(vec![retired], "2026-05-07T00:00:00Z");
assert!(changed);
let demoted = records
.iter()
.find(|t| t.id == "builtin-team:legacy")
.expect("retired built-in should be retained as a custom team");
assert!(!demoted.is_builtin);
assert_eq!(demoted.updated_at, "2026-05-07T00:00:00Z");
}
#[test]
fn merge_teams_repromotes_existing_builtin_marked_as_custom() {
// If someone hand-edits the store and flips is_builtin to false on a
// canonical built-in id, merge_teams should restore the flag.
let mut downgraded = team("builtin-team:fizz", "Fizz");
downgraded.is_builtin = false;
let (records, changed) = merge_teams(vec![downgraded], "2026-05-07T00:00:00Z");
assert!(changed);
let fizz = records
.iter()
.find(|t| t.id == "builtin-team:fizz")
.expect("Fizz should exist");
assert!(fizz.is_builtin);
assert_eq!(records.len(), 1);
}
#[test]
@@ -327,6 +327,10 @@ pub struct CreateManagedAgentRequest {
pub system_prompt: Option<String>,
pub avatar_url: Option<String>,
pub model: Option<String>,
/// LLM inference provider for flat (persona-less) creates. A linked
/// persona's snapshot provider wins when both are present.
#[serde(default)]
pub provider: Option<String>,
pub mcp_toolsets: Option<String>,
/// Environment variables for this agent. Layered on top of persona env.
#[serde(default)]
@@ -555,7 +559,14 @@ pub struct TeamRecord {
pub id: String,
pub name: String,
pub description: Option<String>,
/// Persona-backed members. Only pack-installed (directory-backed) teams
/// still reference personas; ad-hoc teams use `agent_pubkeys`.
#[serde(default)]
pub persona_ids: Vec<String>,
/// Managed-agent members, referenced by agent pubkey (hex). The primary
/// membership mechanism for user-created teams.
#[serde(default)]
pub agent_pubkeys: Vec<String>,
#[serde(default)]
pub is_builtin: bool,
/// Absolute path to the team's backing directory (if directory-backed).
@@ -581,6 +592,8 @@ pub struct CreateTeamRequest {
pub description: Option<String>,
#[serde(default)]
pub persona_ids: Vec<String>,
#[serde(default)]
pub agent_pubkeys: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -591,6 +604,8 @@ pub struct UpdateTeamRequest {
pub description: Option<String>,
#[serde(default)]
pub persona_ids: Vec<String>,
#[serde(default)]
pub agent_pubkeys: Vec<String>,
}
/// Result of syncing a directory-backed team with its backing directory.