mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user