fix(ci): restore main to green — tauri fmt, personas.rs file-size split, Windows path test (#1399)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-30 10:47:49 -06:00
committed by GitHub
parent b74ed858ff
commit 67c47de69c
5 changed files with 2200 additions and 2026 deletions

View File

@@ -74,14 +74,6 @@ const overrides = new Map([
// re-introduced after 826d735fe removal (config-bridge caller still needs it).
// PGID resolution helper + PID-recycling safety guard added for orphan sweep.
["src-tauri/src/managed_agents/runtime.rs", 2150],
// Phase-2 inbound reconcile + review-fix cycle: reconcile_inbound_persona_event
// dispatches 30175/30176/30177 inbound plus kind:5 tombstone consume
// (reconcile_inbound_tombstone), the two apply_inbound_* fns, the
// event_d_tag/parse_deletion_coordinate helpers, and the preserve/overwrite +
// secret-injection + tombstone test coverage. Load-bearing feature growth,
// queued to split with the list. The two `agents-data-changed` emits (live
// UI refresh on inbound reconcile + tombstone) add the latest growth.
["src-tauri/src/commands/personas.rs", 1279],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
//! Tests for inbound persona/team/managed-agent reconciliation.
//! Extracted from the parent module to keep it under the file-size cap.
use super::*;
use std::collections::BTreeMap;
const UUID: &str = "11111111-2222-3333-4444-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.
fn local_in_app() -> PersonaRecord {
PersonaRecord {
id: UUID.to_string(),
display_name: "Local".to_string(),
avatar_url: None,
system_prompt: "local prompt".to_string(),
runtime: Some("goose".to_string()),
model: Some("opus".to_string()),
provider: Some("anthropic".to_string()),
name_pool: vec!["Local".to_string()],
is_builtin: false,
is_active: true,
source_team: Some("team-1".to_string()),
source_team_persona_slug: None,
env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]),
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
}
}
/// An inbound persona as `persona_from_event` would produce it: id = d-tag,
/// slug = Some(d-tag), empty env_vars, source_team None.
fn inbound_for(d_tag: &str, display_name: &str) -> PersonaRecord {
PersonaRecord {
id: d_tag.to_string(),
display_name: display_name.to_string(),
avatar_url: Some("https://example.com/a.png".to_string()),
system_prompt: "remote prompt".to_string(),
runtime: Some("acp".to_string()),
model: Some("sonnet".to_string()),
provider: Some("openai".to_string()),
name_pool: vec!["Remote".to_string()],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: Some(d_tag.to_string()),
env_vars: BTreeMap::new(),
created_at: "2025-06-01T00:00:00Z".to_string(),
updated_at: "2025-06-01T00:00:00Z".to_string(),
}
}
#[test]
fn in_app_persona_matches_existing_uuid_and_patches() {
let mut personas = vec![local_in_app()];
apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote"));
assert_eq!(personas.len(), 1, "no duplicate row");
let p = &personas[0];
// Projected fields patched.
assert_eq!(p.display_name, "Remote");
assert_eq!(p.system_prompt, "remote prompt");
assert_eq!(p.provider, Some("openai".to_string()));
// Local identity + secrets + lineage preserved.
assert_eq!(p.id, UUID);
assert_eq!(p.env_vars.get("API_KEY"), Some(&"secret".to_string()));
assert_eq!(p.source_team, Some("team-1".to_string()));
assert_eq!(p.source_team_persona_slug, None);
assert_eq!(p.created_at, "2025-01-01T00:00:00Z");
}
#[test]
fn re_received_in_app_persona_is_idempotent_no_duplicate() {
let mut personas = vec![local_in_app()];
apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote"));
// Same event arrives again (e.g. reconnect backfill).
apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote"));
assert_eq!(personas.len(), 1, "re-receive must not duplicate");
assert_eq!(personas[0].id, UUID);
}
#[test]
fn team_persona_matches_on_slug_and_patches() {
let mut local = local_in_app();
local.id = "local-uuid".to_string();
local.source_team_persona_slug = Some("team-slug".to_string());
let mut personas = vec![local];
apply_inbound_persona(&mut personas, inbound_for("team-slug", "Renamed"));
assert_eq!(personas.len(), 1, "no duplicate row");
assert_eq!(personas[0].display_name, "Renamed");
// Local UUID survives even though the match key is the slug.
assert_eq!(personas[0].id, "local-uuid");
assert_eq!(
personas[0].source_team_persona_slug,
Some("team-slug".to_string())
);
}
#[test]
fn no_local_match_inserts_inbound_reusing_d_tag_as_id() {
let mut personas = vec![local_in_app()];
let other = "99999999-8888-7777-6666-555555555555";
apply_inbound_persona(&mut personas, inbound_for(other, "New"));
assert_eq!(personas.len(), 2, "unmatched inbound is inserted");
let inserted = personas.iter().find(|p| p.id == other).unwrap();
assert_eq!(inserted.display_name, "New");
// Re-receiving the inserted record must still be idempotent.
apply_inbound_persona(&mut personas, inbound_for(other, "New"));
assert_eq!(personas.len(), 2, "re-receive of inserted record no-ops");
}
// ── Managed-agent (30177) inbound ────────────────────────────────────────
const AGENT_PUBKEY: &str = "agentpubkeyhex0000000000000000000000000000000000000000000000000000";
/// A local managed agent carrying every device-local secret that an inbound
/// event must NEVER be able to overwrite.
fn local_agent() -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: AGENT_PUBKEY.to_string(),
name: "Local Agent".to_string(),
persona_id: Some("persona-local".to_string()),
private_key_nsec: "nsec1localsecret".to_string(),
auth_tag: Some("localauthtag".to_string()),
relay_url: "wss://relay.local".to_string(),
avatar_url: None,
acp_command: "buzz-acp".to_string(),
agent_command: "goose".to_string(),
agent_command_override: Some("claude".to_string()),
agent_args: vec![],
mcp_command: "buzz-dev-mcp".to_string(),
turn_timeout_seconds: 320,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 8,
system_prompt: Some("local prompt".to_string()),
model: Some("local-model".to_string()),
provider: Some("local-provider".to_string()),
persona_source_version: Some("local-hash".to_string()),
mcp_toolsets: Some("local".to_string()),
env_vars: BTreeMap::from([("API_KEY".to_string(), "localsecret".to_string())]),
start_on_app_launch: true,
runtime_pid: Some(1234),
backend: crate::managed_agents::BackendKind::Provider {
id: "buzz-backend".to_string(),
config: serde_json::json!({ "api_key": "localproviderkey" }),
},
backend_agent_id: Some("local-remote-id".to_string()),
provider_binary_path: Some("/local/bin".to_string()),
persona_team_dir: None,
persona_name_in_team: None,
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
respond_to: crate::managed_agents::RespondTo::OwnerOnly,
respond_to_allowlist: vec![],
relay_mesh: None,
}
}
/// Sign a kind:30177 event whose content JSON carries the legitimate
/// projected fields PLUS injected secret/harness keys — a hostile relay
/// event trying to smuggle credentials onto the apply path.
fn foreign_agent_event_with_secrets(d_tag: &str) -> nostr::Event {
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
let content = serde_json::json!({
"name": "Remote Agent",
"persona_id": "persona-remote",
"system_prompt": "remote prompt",
"model": "remote-model",
"provider": "remote-provider",
"mcp_toolsets": "remote",
"persona_source_version": "remote-hash",
"parallelism": 99,
"respond_to": "anyone",
"respond_to_allowlist": ["deadbeef"],
// Injected — must be dropped at deserialization, never applied.
"private_key_nsec": "nsec1INJECTEDSECRET",
"auth_tag": "INJECTEDAUTHTAG",
"env_vars": { "API_KEY": "INJECTEDKEY" },
"agent_command": "INJECTEDHARNESS",
"agent_command_override": "INJECTEDOVERRIDE",
"backend": { "type": "provider", "id": "x", "config": { "k": "INJECTEDBACKEND" } },
"mcp_command": "INJECTEDMCP",
});
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(30177), content.to_string())
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
// Round-trip through JSON to mirror the wire path the reconcile command
// parses from.
nostr::Event::from_json(event.as_json()).unwrap()
}
/// Direct-backend secret-preservation: drive the real parser + apply against
/// a foreign event crammed with secrets and assert NONE land on the local
/// record, and that every projected field IS updated. The projection type is
/// the structural guard — the injected keys cannot even be represented.
#[test]
fn inbound_managed_agent_drops_injected_secrets_and_harness() {
let event = foreign_agent_event_with_secrets(AGENT_PUBKEY);
let content =
crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap();
let mut agents = vec![local_agent()];
apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);
let a = &agents[0];
// Secrets / harness / runtime — every one preserved from the local record.
assert_eq!(
a.private_key_nsec, "nsec1localsecret",
"secret key overwritten"
);
assert_eq!(
a.auth_tag,
Some("localauthtag".to_string()),
"auth tag overwritten"
);
assert_eq!(
a.env_vars.get("API_KEY"),
Some(&"localsecret".to_string()),
"env var overwritten"
);
assert_eq!(a.agent_command, "goose", "harness command overwritten");
assert_eq!(
a.agent_command_override,
Some("claude".to_string()),
"harness override overwritten"
);
assert_eq!(a.mcp_command, "buzz-dev-mcp", "mcp command overwritten");
assert_eq!(a.relay_url, "wss://relay.local", "relay url overwritten");
assert_eq!(a.runtime_pid, Some(1234), "runtime pid overwritten");
match &a.backend {
crate::managed_agents::BackendKind::Provider { config, .. } => {
assert_eq!(
config["api_key"], "localproviderkey",
"backend blob overwritten"
);
}
_ => panic!("backend kind changed"),
}
// No injected value appears anywhere on the serialized record.
let json = serde_json::to_string(a).unwrap();
for needle in [
"INJECTEDSECRET",
"INJECTEDAUTHTAG",
"INJECTEDKEY",
"INJECTEDHARNESS",
"INJECTEDOVERRIDE",
"INJECTEDBACKEND",
"INJECTEDMCP",
] {
assert!(!json.contains(needle), "injected value leaked: {needle}");
}
// Projected fields ARE updated from the inbound event.
assert_eq!(a.name, "Remote Agent");
assert_eq!(a.system_prompt, Some("remote prompt".to_string()));
assert_eq!(a.model, Some("remote-model".to_string()));
assert_eq!(a.provider, Some("remote-provider".to_string()));
assert_eq!(a.parallelism, 99);
assert_eq!(a.respond_to, crate::managed_agents::RespondTo::Anyone);
assert_eq!(a.respond_to_allowlist, vec!["deadbeef".to_string()]);
}
#[test]
fn inbound_managed_agent_no_match_is_noop() {
let event = foreign_agent_event_with_secrets("someotheragentpubkey");
let content =
crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap();
let mut agents = vec![local_agent()];
apply_inbound_managed_agent(&mut agents, "someotheragentpubkey", content);
// No agent minted from a relay event — it would have no secret key.
assert_eq!(agents.len(), 1);
assert_eq!(
agents[0].name, "Local Agent",
"unmatched inbound must not touch the local record"
);
}
// ── Team (30176) inbound ─────────────────────────────────────────────────
const TEAM_ID: &str = "team-local-id";
fn local_team() -> TeamRecord {
TeamRecord {
id: TEAM_ID.to_string(),
name: "Local Team".to_string(),
description: Some("local desc".to_string()),
persona_ids: vec!["p-local".to_string()],
is_builtin: false,
source_dir: Some(std::path::PathBuf::from("/local/team/dir")),
is_symlink: true,
symlink_target: Some("/external".to_string()),
version: Some("1.0".to_string()),
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
}
}
fn team_content(name: &str) -> TeamEventContent {
TeamEventContent {
name: name.to_string(),
description: Some("remote desc".to_string()),
persona_ids: vec!["p-remote-1".to_string(), "p-remote-2".to_string()],
}
}
#[test]
fn inbound_team_match_patches_shared_preserves_local() {
let mut teams = vec![local_team()];
apply_inbound_team(
&mut teams,
TEAM_ID.to_string(),
team_content("Renamed Team"),
);
assert_eq!(teams.len(), 1, "no duplicate row");
let t = &teams[0];
// Shared fields overwritten.
assert_eq!(t.name, "Renamed Team");
assert_eq!(t.description, Some("remote desc".to_string()));
assert_eq!(
t.persona_ids,
vec!["p-remote-1".to_string(), "p-remote-2".to_string()]
);
// Install-local fields preserved.
assert_eq!(t.id, TEAM_ID);
assert_eq!(
t.source_dir,
Some(std::path::PathBuf::from("/local/team/dir"))
);
assert!(t.is_symlink);
assert_eq!(t.symlink_target, Some("/external".to_string()));
assert_eq!(t.version, Some("1.0".to_string()));
assert_eq!(t.created_at, "2025-01-01T00:00:00Z");
}
#[test]
fn inbound_team_no_match_inserts_idempotently() {
let mut teams = vec![local_team()];
let other = "team-remote-id";
apply_inbound_team(&mut teams, other.to_string(), team_content("New Team"));
assert_eq!(teams.len(), 2, "unmatched inbound is inserted");
let inserted = teams.iter().find(|t| t.id == other).unwrap();
assert_eq!(inserted.name, "New Team");
assert!(
inserted.source_dir.is_none(),
"inserted team has no local install dir"
);
// Re-receive stays idempotent.
apply_inbound_team(&mut teams, other.to_string(), team_content("New Team"));
assert_eq!(teams.len(), 2, "re-receive of inserted team no-ops");
}
// ── Tombstone (kind:5) consume ────────────────────────────────────────────
fn deletion_event(coord: &str) -> nostr::Event {
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
let event = EventBuilder::new(Kind::Custom(5), "")
.tags(vec![Tag::parse(["a", coord]).unwrap()])
.sign_with_keys(&Keys::generate())
.unwrap();
nostr::Event::from_json(event.as_json()).unwrap()
}
#[test]
fn parse_deletion_coordinate_extracts_kind_and_d_tag() {
let owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
// Persona / team / agent coordinates all route by their leading kind.
let p = deletion_event(&format!("30175:{owner}:my-persona"));
assert_eq!(
parse_deletion_coordinate(&p),
Some((30175, "my-persona".to_string()))
);
let a = deletion_event(&format!("30177:{owner}:agentpubkeyhex"));
assert_eq!(
parse_deletion_coordinate(&a),
Some((30177, "agentpubkeyhex".to_string()))
);
}
#[test]
fn parse_deletion_coordinate_handles_colon_in_d_tag_and_rejects_malformed() {
let owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
// A d-tag containing ':' keeps its remainder intact (splitn(3)).
let weird = deletion_event(&format!("30176:{owner}:a:b:c"));
assert_eq!(
parse_deletion_coordinate(&weird),
Some((30176, "a:b:c".to_string()))
);
// Missing d-tag segment / non-numeric kind → None (no-op).
assert_eq!(
parse_deletion_coordinate(&deletion_event("30175:owner")),
None
);
assert_eq!(
parse_deletion_coordinate(&deletion_event("notakind:owner:d")),
None
);
}
#[test]
fn tombstone_removal_predicates_match_apply_fn_keys() {
// The deletion path removes by the SAME per-kind key the apply fns use.
// Persona: by persona_d_tag (slug/id).
let mut personas = vec![local_in_app()];
let target = persona_d_tag(&personas[0]);
personas.retain(|r| persona_d_tag(r) != target);
assert!(personas.is_empty(), "persona removed by its d-tag");
// Team: by id.
let mut teams = vec![local_team()];
teams.retain(|r| r.id != TEAM_ID);
assert!(teams.is_empty(), "team removed by id");
// Managed-agent: by pubkey. A non-matching d-tag is a no-op.
let mut agents = vec![local_agent()];
agents.retain(|r| r.pubkey != "someoneelse");
assert_eq!(agents.len(), 1, "non-matching agent tombstone no-ops");
agents.retain(|r| r.pubkey != AGENT_PUBKEY);
assert!(agents.is_empty(), "agent removed by pubkey");
}

View File

@@ -0,0 +1,859 @@
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
agent_events::ManagedAgentEventContent, encode_persona_json, load_managed_agents,
load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
parse_zip_personas, persona_events::persona_d_tag, save_managed_agents, save_personas,
team_events::TeamEventContent, team_persona_key, try_regenerate_nest,
validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
ManagedAgentRecord, ParsePersonaFilesResult, PersonaRecord, TeamRecord,
UpdatePersonaRequest,
},
util::now_iso,
};
fn trim_required(value: &str, label: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{label} is required"));
}
Ok(trimmed.to_string())
}
fn trim_optional(value: Option<String>) -> Option<String> {
value.and_then(|candidate| {
let trimmed = candidate.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
}
/// Retain a freshly authored persona event in the local store, flagged for
/// relay sync. Called inside a command's `managed_agents_store_lock`-held body
/// after `save_personas`; the background flush loop publishes it out-of-band.
///
/// The event is signed with the owner keys at call time, so its `created_at`
/// is `now` — newer than any prior retained row, clearing the upsert's
/// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop,
/// which is the sole publisher. Best-effort: a failure here is logged and
/// swallowed so a retention hiccup never blocks the disk-authoritative write.
///
/// Unlike `retain_managed_agent_pending`, this has no projection-equality
/// short-circuit: personas have no start/stop runtime churn, so a republish
/// only happens on a genuine create/update/delete user edit (`set_persona_active`
/// does not retain, so the local-only `is_active` toggle never republishes, and
/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The
/// guard is intentionally omitted.
fn retain_persona_pending(app: &AppHandle, state: &AppState, persona: &PersonaRecord) {
use crate::managed_agents::{
managed_agents_base_dir,
persona_events::{build_persona_event, monotonic_created_at, persona_d_tag},
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
};
use buzz_core_pkg::kind::KIND_PERSONA;
use nostr::JsonUtil;
let result = (|| -> Result<(), String> {
let d_tag = persona_d_tag(persona);
let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?;
let (pubkey, event) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
// Monotonic created_at: read the retained head for this coordinate
// and bump past it (NIP-AP step 3) so a same-second edit supersedes.
let prior =
get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)?
.map(|row| row.created_at);
let event = build_persona_event(persona)?
.custom_created_at(monotonic_created_at(prior))
.sign_with_keys(&keys)
.map_err(|e| format!("failed to sign persona event: {e}"))?;
(keys.public_key().to_hex(), event)
};
retain_event(
&conn,
&RetainedEvent {
kind: KIND_PERSONA,
pubkey,
d_tag,
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
},
)
})();
if let Err(e) = result {
eprintln!("buzz-desktop: persona-retain: {e}");
}
}
/// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both
/// inside the `managed_agents_store_lock`-held delete body.
///
/// PURGE IN: `delete_retained_event` removes the persona's `(30175, pubkey,
/// d_tag)` row. Running it under the same lock that serializes `retain_event`
/// closes the same-second resurrect race — a concurrent edit can't re-insert a
/// pending persona row after the tombstone is queued.
///
/// PUBLISH OUT: the kind:5 tombstone is retained at its own coordinate `(5,
/// pubkey, d_tag)` (distinct from the purged persona row) with `pending_sync =
/// 1`; the flush loop publishes it. Best-effort: a failure is logged and
/// swallowed so a retention hiccup never blocks the disk-authoritative delete.
pub(super) fn tombstone_persona_pending(app: &AppHandle, state: &AppState, d_tag: &str) {
use crate::managed_agents::{
managed_agents_base_dir,
persona_events::build_persona_delete,
retention::{
delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag,
RetainedEvent,
},
};
use buzz_core_pkg::kind::KIND_PERSONA;
use nostr::JsonUtil;
const KIND_DELETE: u32 = 5;
let result = (|| -> Result<(), String> {
let (pubkey, event) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let pubkey = keys.public_key().to_hex();
let event = build_persona_delete(d_tag, &pubkey)?
.sign_with_keys(&keys)
.map_err(|e| format!("failed to sign persona tombstone: {e}"))?;
(pubkey, event)
};
let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?;
// Purge the persona row first so an unpublished edit can never resurrect
// it after the tombstone publishes.
delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?;
retain_event(
&conn,
&RetainedEvent {
kind: KIND_DELETE,
pubkey,
// Key by the target coordinate so cross-kind d-tag tombstones
// occupy distinct rows (F2c).
d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag),
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
},
)
})();
if let Err(e) = result {
eprintln!("buzz-desktop: persona-tombstone: {e}");
}
}
#[tauri::command]
pub fn list_personas(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<PersonaRecord>, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
load_personas(&app)
}
#[tauri::command]
pub fn create_persona(
input: CreatePersonaRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
let display_name = trim_required(&input.display_name, "Display name")?;
// System prompt optional: core memory is auto-injected. Empty is valid.
let system_prompt = input.system_prompt.trim().to_string();
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
let now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let name_pool: Vec<String> = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let persona = PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name,
avatar_url,
system_prompt,
runtime,
model,
provider,
name_pool,
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: input.env_vars,
created_at: now.clone(),
updated_at: now,
};
personas.push(persona.clone());
save_personas(&app, &personas)?;
retain_persona_pending(&app, &state, &persona);
try_regenerate_nest(&app);
Ok(persona)
}
#[tauri::command]
pub fn update_persona(
input: UpdatePersonaRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
let display_name = trim_required(&input.display_name, "Display name")?;
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
// verbatim (including any trailing newline), and write_back_persona_md
// decomposes by suffix-stripping. Trimming would break that exact-suffix
// match for the common case where instructions.md has a trailing newline.
let system_prompt = input.system_prompt.clone();
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let persona = personas
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} not found", input.id))?;
if persona.is_builtin {
return Err("Built-in personas cannot be edited.".to_string());
}
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
persona.runtime = runtime;
persona.model = model;
persona.provider = provider;
persona.name_pool = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if let Some(env_vars) = input.env_vars {
crate::managed_agents::validate_user_env_keys(&env_vars)?;
persona.env_vars = env_vars;
}
persona.updated_at = now_iso();
save_personas(&app, &personas)?;
let result = personas
.into_iter()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
// For pack-backed personas, also write the edit back to the source
// `.persona.md` so that launch sync (which reads the file) becomes a
// no-op rather than overwriting the record we just saved.
write_back_persona_md(&app, &result);
retain_persona_pending(&app, &state, &result);
try_regenerate_nest(&app);
Ok(result)
}
mod writeback;
use writeback::write_back_persona_md;
#[cfg(test)]
mod inbound_tests;
#[tauri::command]
pub fn delete_persona(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let persona = personas
.iter()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_deletion(persona, referenced_by_team)?;
// Capture the coordinate before the record leaves the list. Only reached
// for non-builtin, non-team personas (validate_persona_deletion rejects
// both), so every deleted persona here is one this owner published.
let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona);
let original_len = personas.len();
personas.retain(|record| record.id != id);
if personas.len() == original_len {
return Err(format!("persona {id} not found"));
}
save_personas(&app, &personas)?;
tombstone_persona_pending(&app, &state, &d_tag);
let mut agents = load_managed_agents(&app)?;
let mut changed_agents = false;
let now = now_iso();
for agent in &mut agents {
if agent.persona_id.as_deref() == Some(id.as_str()) {
agent.persona_id = None;
agent.updated_at = now.clone();
changed_agents = true;
}
}
if changed_agents {
save_managed_agents(&app, &agents)?;
}
try_regenerate_nest(&app);
Ok(())
}
/// Apply an inbound kind:30175 persona event from the relay onto the local
/// store. The frontend's live subscription invokes this per event for our own
/// authored coordinate so Device B inherits Device A's edits.
///
/// Retention is a sync channel that writes INTO `personas.json`, never an
/// authoritative read source — `load_personas` is untouched, so every agent
/// keeps resolving its persona by UUID and keeps its provider keys.
///
/// MATCH KEY (single source of truth, both directions): an inbound event
/// matches the local record whose `persona_d_tag(record)` equals the event's
/// d-tag. Reusing the same derivation the outbound path uses guarantees the
/// inbound key can never drift from the outbound key — in particular, an
/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its
/// `id` matches its existing UUID row instead of minting a duplicate.
///
/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`,
/// `source_team`, and `created_at`. On no match: insert the parsed record as-is
/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses
/// its d-tag as the id and a re-received event stays idempotent (no duplicate).
///
/// The retention store decides whether the inbound event wins over a pending
/// local edit (`retain_inbound_event`): `personas.json` is only patched when the
/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with
/// a pending local edit leaves the local record — and its queued publish —
/// untouched.
#[tauri::command]
pub fn reconcile_inbound_persona_event(
event_json: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
use crate::managed_agents::{
agent_events::managed_agent_content_from_event,
load_managed_agents, load_teams, managed_agents_base_dir,
persona_events::persona_from_event,
retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent},
save_managed_agents, save_teams,
team_events::team_content_from_event,
};
use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM};
use nostr::JsonUtil;
let event = nostr::Event::from_json(&event_json)
.map_err(|e| format!("failed to parse inbound event: {e}"))?;
// The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5
// (NIP-09 deletions). d-tags are NOT unique across kinds, so every path
// below dispatches on kind FIRST and only ever touches its own store — a
// cross-kind d-tag collision can never link a team to a persona or agent.
let kind = event.kind.as_u16() as u32;
// kind:5 deletion: a tombstone removes the local record at the coordinate
// in its `a` tag (`<target_kind>:<owner>:<d_tag>`). Handled before the
// upsert dispatch because its coordinate and retention key differ.
if kind == KIND_DELETION {
return reconcile_inbound_tombstone(&event, &app, &state);
}
if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) {
return Ok(());
}
// The d-tag identifies the record within its kind. Persona derives it from
// the parsed record (`persona_d_tag`); team/agent carry it as the event's
// d-tag directly. The persona is parsed once here and reused in the apply
// branch below — team/agent content is parsed in-branch since their d-tag
// comes from the event tag, not the content.
let inbound_persona = (kind == KIND_PERSONA)
.then(|| persona_from_event(&event))
.transpose()?;
let d_tag = match &inbound_persona {
Some(persona) => persona_d_tag(persona),
None => event_d_tag(&event)?,
};
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
// Resolve inbound vs. any pending local edit before touching the store.
let conn = open_retention_db(&managed_agents_base_dir(&app)?.join("retention.db"))?;
let outcome = retain_inbound_event(
&conn,
&RetainedEvent {
kind,
pubkey: event.pubkey.to_hex(),
d_tag: d_tag.clone(),
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: false,
},
)?;
if outcome == InboundOutcome::Skipped {
return Ok(());
}
match kind {
KIND_PERSONA => {
let mut personas = load_personas(&app)?;
// `inbound_persona` is `Some` for KIND_PERSONA (set above).
apply_inbound_persona(
&mut personas,
inbound_persona.expect("persona parsed above"),
);
save_personas(&app, &personas)?;
}
KIND_TEAM => {
let mut teams = load_teams(&app)?;
apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?);
save_teams(&app, &teams)?;
}
KIND_MANAGED_AGENT => {
let mut agents = load_managed_agents(&app)?;
apply_inbound_managed_agent(
&mut agents,
&d_tag,
managed_agent_content_from_event(&event)?,
);
save_managed_agents(&app, &agents)?;
}
_ => unreachable!("kind gated above"),
}
try_regenerate_nest(&app);
// Signal the live UI to refetch agents data — inbound relay events otherwise
// land on disk silently, leaving the Agents tab stale until restart.
let _ = app.emit("agents-data-changed", ());
Ok(())
}
/// Parse a NIP-09 `a`-tag coordinate `<kind>:<owner_pubkey>:<d_tag>` into its
/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so
/// the caller no-ops on a tombstone it can't route.
fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> {
event.tags.iter().find_map(|tag| {
let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if values.first() != Some(&"a") {
return None;
}
let coord = values.get(1)?;
// `<kind>:<owner>:<d_tag>` — d_tag may itself contain ':' so split at
// most twice and keep the remainder as the d_tag.
let mut parts = coord.splitn(3, ':');
let kind: u32 = parts.next()?.parse().ok()?;
let _owner = parts.next()?;
let d_tag = parts.next()?;
Some((kind, d_tag.to_string()))
})
}
/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the
/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine —
/// retention resolution under the store lock, then a per-kind store mutation —
/// but removes rather than patches. Unknown/malformed coordinates no-op.
fn reconcile_inbound_tombstone(
event: &nostr::Event,
app: &AppHandle,
state: &AppState,
) -> Result<(), String> {
use crate::managed_agents::{
load_managed_agents, load_teams, managed_agents_base_dir,
retention::{
open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome,
RetainedEvent,
},
save_managed_agents, save_teams,
};
use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM};
use nostr::JsonUtil;
let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else {
return Ok(()); // no routable coordinate — nothing to delete
};
if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) {
return Ok(()); // deletion for a kind we don't track locally
}
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
// Resolve against the retained tombstone row (keyed by the target
// coordinate, F2c) so a re-received tombstone or one older than a pending
// local edit is a no-op.
let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?;
let outcome = retain_inbound_event(
&conn,
&RetainedEvent {
kind: KIND_DELETION,
pubkey: event.pubkey.to_hex(),
d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag),
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: false,
},
)?;
if outcome == InboundOutcome::Skipped {
return Ok(());
}
// Remove the local record using the SAME per-kind match rule the apply fns
// use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`.
match target_kind {
KIND_PERSONA => {
let mut personas = load_personas(app)?;
personas.retain(|record| persona_d_tag(record) != target_d_tag);
save_personas(app, &personas)?;
}
KIND_TEAM => {
let mut teams = load_teams(app)?;
teams.retain(|record| record.id != target_d_tag);
save_teams(app, &teams)?;
}
KIND_MANAGED_AGENT => {
let mut agents = load_managed_agents(app)?;
agents.retain(|record| record.pubkey != target_d_tag);
save_managed_agents(app, &agents)?;
}
_ => unreachable!("target kind gated above"),
}
try_regenerate_nest(app);
// Refresh the live UI on inbound deletion — a removal is as user-visible as
// an upsert and the Agents tab must drop the tombstoned record without restart.
let _ = app.emit("agents-data-changed", ());
Ok(())
}
/// Extract the `d` tag value from an event, the match key for team (= team id)
/// and managed-agent (= agent pubkey) inbound reconcile.
fn event_d_tag(event: &nostr::Event) -> Result<String, String> {
event
.tags
.iter()
.find_map(|tag| {
let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
(values.first() == Some(&"d"))
.then(|| values.get(1).map(|s| s.to_string()))
.flatten()
})
.ok_or_else(|| "inbound event missing d-tag".to_string())
}
/// Merge a parsed inbound persona into the local set: patch the matching record
/// in place, or push it when none matches.
///
/// The match key is `persona_d_tag` — the same derivation the outbound path
/// uses — so the inbound and outbound keys can never drift. On match, only the
/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and
/// `created_at` survive. On no match, the parsed record is inserted as-is; since
/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as
/// the id and a re-received event stays idempotent (no duplicate row).
fn apply_inbound_persona(personas: &mut Vec<PersonaRecord>, inbound: PersonaRecord) {
let d_tag = persona_d_tag(&inbound);
match personas
.iter_mut()
.find(|record| persona_d_tag(record) == d_tag)
{
Some(local) => {
local.display_name = inbound.display_name;
local.avatar_url = inbound.avatar_url;
local.system_prompt = inbound.system_prompt;
local.runtime = inbound.runtime;
local.model = inbound.model;
local.provider = inbound.provider;
local.name_pool = inbound.name_pool;
local.updated_at = inbound.updated_at;
}
None => personas.push(inbound),
}
}
/// Merge an inbound kind:30177 managed-agent projection into the local set.
///
/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag
/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the
/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`,
/// `env_vars`, `backend`), the harness pins (`agent_command`,
/// `agent_command_override`), and all runtime/local fields are preserved
/// untouched. The projection type carries none of them, so they cannot be
/// reached here even if a foreign event tried to inject them.
///
/// No match is a no-op: managed agents carry device-local secrets and are never
/// minted from a relay event — an agent that does not already exist locally has
/// no secret key to run with, so inserting a secretless shell would be useless
/// and misleading. This diverges from the persona path, which DOES insert on no
/// match (personas are secretless definitions). Flagged in the reconcile docs.
fn apply_inbound_managed_agent(
agents: &mut [ManagedAgentRecord],
d_tag: &str,
inbound: ManagedAgentEventContent,
) {
if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) {
local.name = inbound.name;
local.persona_id = inbound.persona_id;
local.system_prompt = inbound.system_prompt;
local.model = inbound.model;
local.provider = inbound.provider;
local.mcp_toolsets = inbound.mcp_toolsets;
local.persona_source_version = inbound.persona_source_version;
local.parallelism = inbound.parallelism;
local.respond_to = inbound.respond_to;
local.respond_to_allowlist = inbound.respond_to_allowlist;
}
}
/// Merge an inbound kind:30176 team projection into the local set.
///
/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS
/// the team id — see `build_team_event`). On match, overwrite ONLY the three
/// shared fields (`name`, `description`, `persona_ids`); install-specific local
/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`,
/// `version`, `created_at`) are preserved. On no match, insert a fresh record
/// reusing the d-tag as the id so a re-received event stays idempotent —
/// symmetric to the persona path, since a team (like a persona) is a secretless
/// definition that another device may legitimately learn about from the relay.
fn apply_inbound_team(teams: &mut Vec<TeamRecord>, d_tag: String, inbound: TeamEventContent) {
match teams.iter_mut().find(|record| record.id == d_tag) {
Some(local) => {
local.name = inbound.name;
local.description = inbound.description;
local.persona_ids = inbound.persona_ids;
}
None => teams.push(TeamRecord {
id: d_tag,
name: inbound.name,
description: inbound.description,
persona_ids: inbound.persona_ids,
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now_iso(),
updated_at: now_iso(),
}),
}
}
#[tauri::command]
pub fn set_persona_active(
id: String,
active: bool,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let persona = personas
.iter_mut()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
let referenced_by_managed_agent = !active
&& load_managed_agents(&app)?
.iter()
.any(|agent| agent.persona_id.as_deref() == Some(id.as_str()));
let referenced_by_team = !active
&& load_teams(&app)?.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_activation_change(
persona,
active,
referenced_by_managed_agent,
referenced_by_team,
)?;
if persona.is_active == active {
return Ok(persona.clone());
}
persona.is_active = active;
persona.updated_at = now_iso();
let updated = persona.clone();
save_personas(&app, &personas)?;
try_regenerate_nest(&app);
Ok(updated)
}
const MAX_PNG_BYTES: usize = 10 * 1024 * 1024;
const MAX_JSON_BYTES: usize = 5 * 1024 * 1024;
const MAX_ZIP_BYTES: usize = 100 * 1024 * 1024;
const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47];
const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
const JSON_OPEN_BRACE: u8 = 0x7B;
#[tauri::command]
pub fn parse_persona_files(
file_bytes: Vec<u8>,
file_name: String,
) -> Result<ParsePersonaFilesResult, String> {
if file_bytes.len() > MAX_ZIP_BYTES {
return Err("File is too large (max 100 MB).".to_string());
}
if file_bytes.is_empty() {
return Err("File is empty.".to_string());
}
let first_byte = file_bytes[0];
if file_bytes.len() >= 4 {
let magic: [u8; 4] = file_bytes[..4]
.try_into()
.map_err(|_| "Failed to read file header".to_string())?;
if magic == PNG_MAGIC {
if file_bytes.len() > MAX_PNG_BYTES {
return Err("PNG file is too large (max 10 MB).".to_string());
}
let mut preview = parse_png_persona(&file_bytes)?;
preview.source_file = file_name;
return Ok(ParsePersonaFilesResult {
personas: vec![preview],
skipped: vec![],
});
}
if magic == ZIP_MAGIC {
return parse_zip_personas(&file_bytes);
}
}
if first_byte == JSON_OPEN_BRACE {
if file_bytes.len() > MAX_JSON_BYTES {
return Err("JSON file is too large (max 5 MB).".to_string());
}
let mut preview = parse_json_persona(&file_bytes)?;
preview.source_file = file_name;
return Ok(ParsePersonaFilesResult {
personas: vec![preview],
skipped: vec![],
});
}
// .persona.md: YAML frontmatter starts with "---"
let lower_name = file_name.to_ascii_lowercase();
if lower_name.ends_with(".persona.md") {
if file_bytes.len() > MAX_JSON_BYTES {
return Err("Markdown file is too large (max 5 MB).".to_string());
}
let mut preview = parse_md_persona(&file_bytes)?;
preview.source_file = file_name;
return Ok(ParsePersonaFilesResult {
personas: vec![preview],
skipped: vec![],
});
}
// If it's a .md file but not .persona.md, give a specific hint.
if lower_name.ends_with(".md") {
return Err(
"Only .persona.md files are supported. Rename to <name>.persona.md".to_string(),
);
}
Err(
"Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip"
.to_string(),
)
}
#[tauri::command]
pub async fn export_persona_to_json(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
// Load persona data under lock, then drop lock before dialog.
//
// NOTE: `env_vars` are deliberately NOT included in the exported card.
// Persona cards are designed to be shareable artifacts (uploaded,
// forked, distributed), and bundling API keys / credentials in them
// would be a significant footgun. Users who import a card and need
// credentials must supply them post-import via the persona dialog.
let (display_name, system_prompt, avatar_url, runtime, model, provider, name_pool) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let personas = load_personas(&app)?;
let persona = personas
.iter()
.find(|p| p.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
(
persona.display_name.clone(),
persona.system_prompt.clone(),
persona.avatar_url.clone(),
persona.runtime.clone(),
persona.model.clone(),
persona.provider.clone(),
persona.name_pool.clone(),
)
};
let json_bytes = encode_persona_json(
&display_name,
&system_prompt,
avatar_url.as_deref(),
runtime.as_deref(),
model.as_deref(),
provider.as_deref(),
&name_pool,
)?;
let slug = crate::util::slugify(&display_name, "persona", 50);
let filename = format!("{slug}.persona.json");
save_json_with_dialog(&app, &filename, &json_bytes).await
}

View File

@@ -0,0 +1,910 @@
//! Pack-backed persona write-back: resolve the source `.persona.md` via the
//! pack manifest and rewrite frontmatter fields. Extracted from the parent
//! module to keep it under the desktop file-size cap. Pure relocation.
use super::*;
/// Find the team whose `team_persona_key` equals `source_team`. This matches
/// the same key that `sync_team_from_dir` uses, covering both modern teams
/// (where `team.id` equals the manifest directory name) and legacy/backfilled
/// teams (where `team.id` is a UUID and the manifest id is `source_dir.file_name()`).
fn find_team_for_persona_source<'a>(
teams: &'a [TeamRecord],
source_team: &str,
) -> Option<&'a TeamRecord> {
teams
.iter()
.find(|t| t.id == source_team || team_persona_key(t) == source_team)
}
/// Write updated frontmatter fields back to the source `.persona.md` file for
/// pack-backed personas (`source_team` is set). Non-fatal: any miss (no
/// `source_dir`, missing file, pack load failure, parse or write error) is
/// logged and swallowed so that the in-app edit — already persisted to
/// `personas.json` — always lands.
///
/// Only the four fields that the UI can set and that live in frontmatter are
/// rewritten: `display_name`, `runtime`, `avatar`, and `model` (the combined
/// `"provider:model"` string used by the pack format). The markdown body is
/// preserved byte-for-byte because `PersonaRecord.system_prompt` is the
/// _composed_ prompt (body + pack instructions appended by `compose_prompt`)
/// and writing it back to the file would cause the instructions to be
/// double-appended on the next launch sync.
///
/// The source file path is derived from the pack manifest via
/// `buzz_persona_pkg::pack::load_pack` — the same resolution the launch sync
/// uses — rather than reconstructed by convention. This ensures write-back
/// targets the correct file regardless of where the manifest places the
/// `.persona.md` (e.g. `personas/` vs `agents/`, nested paths, or filenames
/// that differ from the persona `name:` field).
///
/// The team is located via `find_team_for_persona_source`, which matches the
/// same key as `sync_team_from_dir` (`team_persona_key`). This handles both
/// modern teams (where `team.id` equals the manifest id) and legacy/backfilled
/// teams (where `team.id` is a UUID and the manifest id lives in `source_dir`).
pub(super) fn write_back_persona_md(app: &AppHandle, persona: &PersonaRecord) {
// Only pack-backed personas have a source file to write back to.
let Some(source_team_id) = &persona.source_team else {
return;
};
let Some(slug) = &persona.source_team_persona_slug else {
eprintln!(
"buzz-desktop: persona-writeback: persona {} has source_team but no slug; skipping",
persona.id
);
return;
};
let result = (|| -> Result<(), String> {
let teams = load_teams(app)?;
let team = find_team_for_persona_source(&teams, source_team_id)
.ok_or_else(|| format!("team {source_team_id} not found"))?;
let source_dir = team
.source_dir
.as_ref()
.ok_or_else(|| "team has no source_dir (JSON-only team)".to_string())?;
// Resolve the actual source file via the pack manifest, matching the
// same path the launch sync reads. `LoadedPersona.source_path` is the
// absolute path set by `safe_resolve` against the pack root, so it is
// correct regardless of the manifest layout.
let pack = buzz_persona_pkg::pack::load_pack(source_dir)
.map_err(|e| format!("load_pack {}: {e}", source_dir.display()))?;
let loaded = pack
.personas
.iter()
.find(|p| p.name == *slug)
.ok_or_else(|| {
format!(
"persona '{slug}' not found in pack at {}",
source_dir.display()
)
})?;
let path = &loaded.source_path;
let content =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let updated = rewrite_persona_md(
&content,
persona,
&loaded.prompt,
pack.pack_instructions.as_deref(),
)?;
if updated == content {
return Ok(());
}
std::fs::write(path, &updated).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(())
})();
if let Err(e) = result {
eprintln!("buzz-desktop: persona-writeback: {e}");
}
}
/// Rewrite a `.persona.md` file with updated frontmatter fields and, when safe,
/// an updated body (system prompt). Returns the full rewritten file content, or
/// the original unchanged when the result would be byte-identical.
///
/// **Frontmatter fields rewritten:** `display_name`, `runtime`, `avatar`, and
/// `model` (joined `"provider:model"` per the pack format). All other keys and
/// their order are preserved.
///
/// **Body (system prompt) write-back:**
/// The `persona.system_prompt` field holds the *composed* prompt:
/// `compose_prompt(raw_body, pack_instructions)`. To recover the raw body we
/// reverse `compose_prompt`:
///
/// - If `pack_instructions` is absent or blank: new body = `system_prompt`
/// verbatim (no suffix to strip).
/// - If `pack_instructions` is present and non-blank: the composed prompt ends
/// with `"\n\n---\n# Team Instructions\n{instructions}"`. If
/// `system_prompt` ends with that exact suffix, strip it to get the new raw
/// body. **Safety guard**: if the suffix is absent (user edited inside the
/// Team Instructions block, or instructions drifted), we cannot safely
/// recover the raw body — preserve the existing body and log a skip. This
/// prevents a corrupted file or double-appended instructions.
/// - If `system_prompt` equals `compose_prompt(current_raw_body, instructions)`
/// exactly (user did not edit the prompt), the body is preserved
/// byte-for-byte (no-op for the body section).
fn rewrite_persona_md(
content: &str,
persona: &PersonaRecord,
current_raw_body: &str,
pack_instructions: Option<&str>,
) -> Result<String, String> {
let (frontmatter, existing_body) = buzz_persona_pkg::persona::split_frontmatter(content)
.map_err(|e| format!("split_frontmatter: {e:?}"))?;
let mut value = serde_yaml::from_str::<serde_yaml::Value>(frontmatter)
.map_err(|e| format!("yaml parse: {e}"))?;
let mapping = value
.as_mapping_mut()
.ok_or("frontmatter is not a YAML mapping")?;
// display_name
mapping.insert(
serde_yaml::Value::String("display_name".to_string()),
serde_yaml::Value::String(persona.display_name.clone()),
);
// runtime: set when Some, remove when None
let runtime_key = serde_yaml::Value::String("runtime".to_string());
match &persona.runtime {
Some(rt) if !rt.is_empty() => {
mapping.insert(runtime_key, serde_yaml::Value::String(rt.clone()));
}
_ => {
mapping.remove(&runtime_key);
}
}
// avatar: set when Some, remove when None
let avatar_key = serde_yaml::Value::String("avatar".to_string());
match &persona.avatar_url {
Some(av) if !av.is_empty() => {
mapping.insert(avatar_key, serde_yaml::Value::String(av.clone()));
}
_ => {
mapping.remove(&avatar_key);
}
}
// model: joined "provider:model" or bare "model"; remove when both absent
let model_key = serde_yaml::Value::String("model".to_string());
match (&persona.provider, &persona.model) {
(Some(prov), Some(mdl)) if !prov.is_empty() && !mdl.is_empty() => {
mapping.insert(
model_key,
serde_yaml::Value::String(format!("{prov}:{mdl}")),
);
}
(_, Some(mdl)) if !mdl.is_empty() => {
mapping.insert(model_key, serde_yaml::Value::String(mdl.clone()));
}
_ => {
mapping.remove(&model_key);
}
}
let updated_frontmatter =
serde_yaml::to_string(&value).map_err(|e| format!("yaml serialize: {e}"))?;
// Determine the body to write back.
// `compose_prompt` is: body + "\n\n---\n# Team Instructions\n{instructions}"
// when instructions is non-blank, or body verbatim when absent/blank.
let effective_instructions = pack_instructions.filter(|s| !s.trim().is_empty());
let expected_composed = match effective_instructions {
Some(instr) => format!("{current_raw_body}\n\n---\n# Team Instructions\n{instr}"),
None => current_raw_body.to_owned(),
};
let new_body: &str = if persona.system_prompt == expected_composed {
// User did not edit the prompt — keep the existing body byte-for-byte.
existing_body
} else {
// User edited the prompt. Recover the raw body by reversing compose_prompt.
match effective_instructions {
None => {
// No pack instructions: composed == raw, write verbatim.
&persona.system_prompt
}
Some(instr) => {
let suffix = format!("\n\n---\n# Team Instructions\n{instr}");
if let Some(raw) = persona.system_prompt.strip_suffix(suffix.as_str()) {
raw
} else {
// Safety guard: suffix absent — cannot safely recover raw body.
// Preserve the existing body to avoid corruption or double-append.
eprintln!(
"buzz-desktop: persona-writeback: \
system_prompt does not end with expected Team Instructions suffix; \
preserving existing body to avoid corruption"
);
existing_body
}
}
}
};
Ok(format!("---\n{updated_frontmatter}---\n{new_body}"))
}
#[cfg(test)]
mod writeback_tests {
use super::*;
use std::collections::BTreeMap;
/// Build a minimal PersonaRecord with the fields that `rewrite_persona_md` reads.
fn persona(
display_name: &str,
runtime: Option<&str>,
avatar_url: Option<&str>,
provider: Option<&str>,
model: Option<&str>,
) -> PersonaRecord {
// system_prompt matches the SAMPLE_MD body so the "no prompt edit" path
// is taken in rewrite_persona_md (body preserved byte-for-byte).
persona_with_prompt(
display_name,
runtime,
avatar_url,
provider,
model,
"You are Paul.\n",
)
}
/// Like `persona` but with an explicit system_prompt value.
fn persona_with_prompt(
display_name: &str,
runtime: Option<&str>,
avatar_url: Option<&str>,
provider: Option<&str>,
model: Option<&str>,
system_prompt: &str,
) -> PersonaRecord {
PersonaRecord {
id: "test-id".to_string(),
display_name: display_name.to_string(),
avatar_url: avatar_url.map(str::to_string),
system_prompt: system_prompt.to_string(),
runtime: runtime.map(str::to_string),
model: model.map(str::to_string),
provider: provider.map(str::to_string),
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: Some("team-1".to_string()),
source_team_persona_slug: Some("paul".to_string()),
env_vars: BTreeMap::new(),
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
}
}
const SAMPLE_MD: &str = "\
---
name: paul
display_name: \"Paul\"
description: \"An orchestrator.\"
model: goose-claude-4-6-opus
runtime: goose
extra_key: keep-me
---
You are Paul.
";
// ── rewrite_persona_md unit tests ─────────────────────────────────────────
#[test]
fn test_rewrite_model_provider_joined_and_body_preserved() {
let p = persona(
"Paul",
Some("goose"),
None,
Some("databricks_v2"),
Some("goose-claude-opus-4-8"),
);
let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap();
// Body is byte-preserved.
assert!(
result.ends_with("\nYou are Paul.\n"),
"body not preserved: {result:?}"
);
// model key is the joined form.
assert!(
result.contains("model: databricks_v2:goose-claude-opus-4-8"),
"joined model missing: {result}"
);
// No separate provider key.
assert!(
!result.contains("provider:"),
"separate provider key must not be emitted: {result}"
);
// Unrelated key preserved.
assert!(
result.contains("extra_key: keep-me"),
"extra key lost: {result}"
);
// Still valid frontmatter (parses cleanly).
assert!(result.starts_with("---\n"), "must start with ---");
}
#[test]
fn test_rewrite_bare_model_when_provider_none() {
let p = persona("Paul", Some("goose"), None, None, Some("bare-model-id"));
let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap();
assert!(
result.contains("model: bare-model-id"),
"bare model missing: {result}"
);
assert!(
!result.contains("provider:"),
"provider key must not be emitted: {result}"
);
}
#[test]
fn test_rewrite_runtime_removed_when_none() {
let p = persona("Paul", None, None, None, Some("some-model"));
let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap();
// runtime was in source but persona.runtime is None — key must be removed.
assert!(
!result.contains("runtime:"),
"runtime key should be removed when None: {result}"
);
}
#[test]
fn test_rewrite_preserves_description_and_name_and_extra_keys() {
let p = persona(
"Paul Updated",
Some("goose"),
None,
Some("anthropic"),
Some("claude-opus-4"),
);
let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap();
// name and description are not persona record fields — must survive untouched.
assert!(result.contains("name: paul"), "name key lost: {result}");
assert!(
result.contains("description:"),
"description key lost: {result}"
);
assert!(
result.contains("extra_key: keep-me"),
"extra_key lost: {result}"
);
// display_name updated.
assert!(
result.contains("display_name: Paul Updated")
|| result.contains("display_name: \"Paul Updated\""),
"display_name not updated: {result}"
);
}
#[test]
fn test_rewrite_no_provider_no_model_removes_model_key() {
let p = persona("Paul", None, None, None, None);
let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap();
// When both provider and model are cleared, the model key is removed.
assert!(
!result.contains("model:"),
"model key should be removed when both absent: {result}"
);
}
#[test]
fn test_rewrite_avatar_set_and_cleared() {
let with_avatar = persona(
"Paul",
Some("goose"),
Some("data:image/png;base64,abc"),
Some("openai"),
Some("gpt-4o"),
);
let result = rewrite_persona_md(SAMPLE_MD, &with_avatar, "You are Paul.\n", None).unwrap();
assert!(
result.contains("avatar:"),
"avatar key should be set: {result}"
);
let without_avatar = persona("Paul", Some("goose"), None, Some("openai"), Some("gpt-4o"));
let result =
rewrite_persona_md(SAMPLE_MD, &without_avatar, "You are Paul.\n", None).unwrap();
assert!(
!result.contains("avatar:"),
"avatar key should be absent when None: {result}"
);
}
#[test]
fn test_rewrite_body_not_replaced_by_system_prompt() {
// system_prompt on the PersonaRecord is the COMPOSED prompt (body + pack instructions).
// The body of the .persona.md must not be replaced with it.
let p = persona(
"Paul",
Some("goose"),
None,
Some("databricks_v2"),
Some("goose-claude-opus-4-8"),
);
let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap();
// The raw body from the file ("You are Paul.") is preserved.
assert!(
result.ends_with("You are Paul.\n"),
"raw body must be preserved, not replaced by composed system_prompt: {result:?}"
);
// The composed instructions suffix must NOT appear in the file body.
assert!(
!result.contains("# Team Instructions"),
"composed system_prompt must not be written to body: {result}"
);
}
// ── body (system_prompt) write-back tests ─────────────────────────────────
//
// These tests exercise the compose_prompt inversion logic in rewrite_persona_md.
/// Frontmatter-only MD (no body to preserve) for prompt tests.
const PROMPT_MD: &str = "\
---
name: paul
display_name: \"Paul\"
model: goose-claude-4-6-opus
---
You are Paul.
";
#[test]
fn test_prompt_edited_with_pack_instructions_body_rewritten() {
// User edits the prompt. system_prompt = new_raw_body + separator + instructions.
// Body in file should be updated to new_raw_body; Team Instructions must NOT appear.
let instructions = "Follow the rules.";
let new_raw_body = "You are Paul, a wise orchestrator.";
let composed = format!("{new_raw_body}\n\n---\n# Team Instructions\n{instructions}");
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
p.system_prompt = composed;
let result =
rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap();
assert!(
result.ends_with("You are Paul, a wise orchestrator."),
"new body not written: {result:?}"
);
assert!(
!result.contains("# Team Instructions"),
"Team Instructions must not appear in body: {result}"
);
}
#[test]
fn test_prompt_edited_no_pack_instructions_body_rewritten_verbatim() {
// No pack instructions: composed == raw. New body is system_prompt verbatim.
let new_raw_body = "You are Paul, updated.";
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
p.system_prompt = new_raw_body.to_string();
let result = rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", None).unwrap();
assert!(
result.ends_with("You are Paul, updated."),
"body not updated: {result:?}"
);
}
#[test]
fn test_prompt_unedited_body_preserved() {
// system_prompt equals compose_prompt(current_raw_body, instructions) exactly.
// The body section must not change even though frontmatter may be rewritten.
let instructions = "Follow the rules.";
let raw_body = "You are Paul.";
let composed = format!("{raw_body}\n\n---\n# Team Instructions\n{instructions}");
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
// Frontmatter also unchanged (matches PROMPT_MD).
p.system_prompt = composed;
let result = rewrite_persona_md(PROMPT_MD, &p, raw_body, Some(instructions)).unwrap();
// Body must remain "You are Paul." (no prompt edit — body section preserved).
assert!(
result.ends_with("You are Paul.\n"),
"body must be unchanged: {result:?}"
);
// Team Instructions must not leak into the body.
assert!(
!result.contains("# Team Instructions"),
"Team Instructions must not appear: {result}"
);
}
#[test]
fn test_prompt_safety_guard_missing_suffix_preserves_body() {
// pack_instructions is non-empty but system_prompt does NOT end with the
// expected suffix (user edited inside the Team Instructions block, or the
// instructions drifted). The existing body must be preserved — no corruption.
let instructions = "Follow the rules.";
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
// system_prompt lacks the expected suffix entirely.
p.system_prompt = "Some rogue prompt with # Team Instructions in the middle".to_string();
let result =
rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap();
// Body preserved from the file.
assert!(
result.ends_with("You are Paul.\n"),
"body must be preserved by safety guard: {result:?}"
);
}
#[test]
fn test_prompt_round_trip_no_double_append() {
// After write-back, running compose_prompt on the written body + instructions
// must reproduce the stored system_prompt exactly. This proves no double-append.
let instructions = "Follow the rules.";
let new_raw_body = "You are Paul, updated for the round-trip.";
let composed = format!("{new_raw_body}\n\n---\n# Team Instructions\n{instructions}");
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
p.system_prompt = composed.clone();
let result =
rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap();
// Extract the written body (everything after the last "---\n").
let written_body = result.split("---\n").last().unwrap();
// Re-compose: body + instructions must equal the original composed prompt.
let recomposed = format!("{written_body}\n\n---\n# Team Instructions\n{instructions}");
assert_eq!(
recomposed, composed,
"round-trip failed — double-append would occur: recomposed={recomposed:?}"
);
}
#[test]
fn test_prompt_edited_with_trailing_newline_in_instructions() {
// Regression: normal instructions.md files have a trailing newline.
// compose_prompt includes it verbatim; system_prompt must NOT be trimmed
// in update_persona or the suffix-strip fails and the safety guard fires.
//
// This test verifies that pack_instructions with a trailing "\n" still
// decomposes correctly — i.e., the body is rewritten, not preserved.
let instructions = "Follow the rules.\n"; // trailing newline from file read
let new_raw_body = "You are Paul, rewritten.";
let composed = format!("{new_raw_body}\n\n---\n# Team Instructions\n{instructions}");
// system_prompt is NOT trimmed (update_persona must preserve it as-is).
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
p.system_prompt = composed;
let result =
rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap();
assert!(
result.contains("You are Paul, rewritten."),
"body not rewritten with trailing-newline instructions: {result:?}"
);
assert!(
!result.contains("# Team Instructions"),
"Team Instructions must not appear in body: {result}"
);
}
// ── write_back_persona_md path-resolution tests ───────────────────────────
//
// These tests verify that write_back_persona_md resolves the source file via
// the pack manifest rather than by convention. This is the class of bug that
// Thufir's IMPORTANT finding caught: a pack whose manifest points at
// `personas/foo.persona.md` (not `agents/<slug>.persona.md`) must still be
// rewritten correctly.
use tempfile::TempDir;
/// Build a minimal pack on disk with the given personas layout.
///
/// `persona_entries` is a list of (manifest_rel_path, file_content) pairs.
/// The manifest lists the relative paths; the files are written verbatim.
fn make_temp_pack(persona_entries: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
std::fs::create_dir_all(root.join(".plugin")).unwrap();
let persona_paths: Vec<&str> = persona_entries.iter().map(|(p, _)| *p).collect();
let manifest = serde_json::json!({
"id": "test-team",
"name": "Test Team",
"version": "0.1.0",
"personas": persona_paths,
});
std::fs::write(
root.join(".plugin/plugin.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
for (rel_path, content) in persona_entries {
let abs = root.join(rel_path);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, content).unwrap();
}
dir
}
#[test]
fn test_writeback_uses_manifest_path_not_convention() {
// Pack uses `personas/paul.persona.md` layout (not `agents/`).
// Convention-based path would derive `agents/paul.persona.md` (wrong).
// Manifest-based resolution must write to the correct `personas/` path.
let persona_md = "\
---
name: paul
display_name: \"Paul\"
description: \"Orchestrator\"
model: goose-claude-4-6-opus
---
You are Paul.
";
let dir = make_temp_pack(&[("personas/paul.persona.md", persona_md)]);
let source_dir = dir.path().to_path_buf();
let pack = buzz_persona_pkg::pack::load_pack(&source_dir).unwrap();
assert_eq!(pack.personas[0].name, "paul");
let source_path = pack.personas[0].source_path.clone();
// File lives under personas/, not agents/. Assert on path components so
// the check is separator-agnostic (Windows uses `\`, not `/`).
assert_eq!(
source_path.file_name().and_then(|n| n.to_str()),
Some("paul.persona.md"),
"expected paul.persona.md: {}",
source_path.display()
);
assert_eq!(
source_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str()),
Some("personas"),
"expected personas/ layout: {}",
source_path.display()
);
// Simulate what write_back_persona_md does after the fix:
// use source_path from pack, not source_dir/agents/<slug>.persona.md
let mut p = persona(
"Paul Updated",
Some("goose"),
None,
Some("databricks_v2"),
Some("goose-claude-opus-4-8"),
);
p.source_team_persona_slug = Some("paul".to_string());
let content = std::fs::read_to_string(&source_path).unwrap();
let updated = rewrite_persona_md(&content, &p, "You are Paul.\n", None).unwrap();
std::fs::write(&source_path, &updated).unwrap();
let after = std::fs::read_to_string(&source_path).unwrap();
assert!(
after.contains("databricks_v2:goose-claude-opus-4-8"),
"model not written: {after}"
);
assert!(
after.ends_with("You are Paul.\n"),
"body not preserved: {after:?}"
);
}
#[test]
fn test_writeback_name_differs_from_filename() {
// Pack file is `personas/orchestrator.persona.md` but `name: paul`.
// Slug matches `name:`, not the filename.
let persona_md = "\
---
name: paul
display_name: \"Paul\"
description: \"Orchestrator\"
model: old-model
---
You are Paul.
";
let dir = make_temp_pack(&[("personas/orchestrator.persona.md", persona_md)]);
let source_dir = dir.path().to_path_buf();
let pack = buzz_persona_pkg::pack::load_pack(&source_dir).unwrap();
let loaded = pack.personas.iter().find(|p| p.name == "paul").unwrap();
let source_path = loaded.source_path.clone();
// File basename is orchestrator, not paul
assert!(
source_path
.to_string_lossy()
.contains("orchestrator.persona.md"),
"expected orchestrator.persona.md: {}",
source_path.display()
);
let mut p = persona(
"Paul",
Some("goose"),
None,
Some("anthropic"),
Some("claude-4"),
);
p.source_team_persona_slug = Some("paul".to_string());
let content = std::fs::read_to_string(&source_path).unwrap();
let updated = rewrite_persona_md(&content, &p, "You are Paul.\n", None).unwrap();
std::fs::write(&source_path, &updated).unwrap();
let after = std::fs::read_to_string(&source_path).unwrap();
assert!(
after.contains("anthropic:claude-4"),
"model not written to orchestrator.persona.md: {after}"
);
// The wrong file (paul.persona.md in agents/) must NOT exist.
assert!(
!dir.path().join("agents/paul.persona.md").exists(),
"convention-based path must not be created"
);
}
// ── find_team_for_persona_source tests ────────────────────────────────────
//
// Verify that write_back_persona_md finds teams by team_persona_key, not
// team.id. Legacy/backfilled teams have a UUID `id` while PersonaRecord
// stores the manifest directory name in `source_team`; matching by `id`
// alone silently misses those teams.
fn make_team(id: &str, source_dir: Option<&str>) -> TeamRecord {
TeamRecord {
id: id.to_string(),
name: id.to_string(),
description: None,
persona_ids: vec![],
is_builtin: false,
source_dir: source_dir.map(|s| std::path::PathBuf::from(s)),
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(),
}
}
#[test]
fn test_find_team_legacy_uuid_id_matched_by_source_dir_name() {
// Legacy shape: team.id is a UUID; PersonaRecord.source_team is the
// manifest directory name. The old `team.id == source_team` predicate
// missed this — find_team_for_persona_source must match via source_dir.
let teams = vec![make_team("some-uuid-123", Some("/teams/com.test.pack"))];
let found = find_team_for_persona_source(&teams, "com.test.pack");
assert!(
found.is_some(),
"legacy team must be found by manifest dir name, not UUID"
);
assert_eq!(found.unwrap().id, "some-uuid-123");
}
#[test]
fn test_find_team_modern_id_matched_directly() {
// Modern shape: team.id equals the manifest directory name. Must match.
let teams = vec![make_team("com.test.pack", Some("/teams/com.test.pack"))];
let found = find_team_for_persona_source(&teams, "com.test.pack");
assert!(found.is_some(), "modern team must be found");
}
#[test]
fn test_find_team_manifest_dir_name_matches_regardless_of_uuid_id() {
// Regression: the old predicate `team.id == source_team` missed legacy
// teams when source_team holds the manifest dir name, not the UUID.
// This test confirms that searching by manifest dir name always works
// even when team.id is a UUID.
let teams = vec![make_team("some-uuid-123", Some("/teams/com.test.pack"))];
// source_team holds the manifest dir name "com.test.pack", not the UUID.
let by_dir = find_team_for_persona_source(&teams, "com.test.pack");
assert!(
by_dir.is_some(),
"manifest dir name must find the legacy team"
);
assert_eq!(by_dir.unwrap().id, "some-uuid-123");
}
#[test]
fn test_find_team_no_source_dir_falls_back_to_id() {
// JSON-only team: no source_dir, team_persona_key falls back to id.
let teams = vec![make_team("builtin-team:fizz", None)];
let found = find_team_for_persona_source(&teams, "builtin-team:fizz");
assert!(
found.is_some(),
"no source_dir: must match via team.id fallback"
);
}
#[test]
fn test_find_team_returns_none_when_no_match() {
let teams = vec![make_team("some-uuid-123", Some("/teams/com.test.pack"))];
let not_found = find_team_for_persona_source(&teams, "com.other.pack");
assert!(not_found.is_none(), "unrelated source_team must not match");
}
#[test]
fn test_multi_save_round_trip_short_circuit_holds_both_times() {
// Regression: the `==` short-circuit must hold on the second save too.
//
// Without the frontend fix (removing systemPrompt.trim()), the first
// save stores the trimmed composed prompt; on re-open the form emits the
// trimmed value, which != compose_prompt(body, instructions\n) — the `==`
// check fails, suffix-strip fails on the trimmed string, and the safety
// guard fires on every subsequent save. This test verifies the path stays
// clean across two consecutive no-edit saves.
let instructions = "Follow the rules.\n"; // realistic: trailing newline from file
// `split_frontmatter` strips the "\n" after the closing "---" delimiter
// but preserves the rest of the body verbatim. PROMPT_MD's body line ends
// with "\n", so loaded.prompt at runtime = "You are Paul.\n".
let (_, raw_body) = buzz_persona_pkg::persona::split_frontmatter(PROMPT_MD).unwrap();
// compose_prompt equivalent (must match the real impl exactly)
let composed = format!("{raw_body}\n\n---\n# Team Instructions\n{instructions}");
// ── Save 1: user opens dialog, saves without editing ─────────────────
let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus"));
p.system_prompt = composed.clone(); // no frontend trim — system_prompt is the full composed value
let written_1 = rewrite_persona_md(PROMPT_MD, &p, raw_body, Some(instructions)).unwrap();
// Short-circuit must have fired: body is preserved (still "You are Paul.\n").
assert!(
written_1.ends_with("You are Paul.\n"),
"save 1: body must be preserved by == short-circuit: {written_1:?}"
);
assert!(
!written_1.contains("# Team Instructions"),
"save 1: Team Instructions must not appear in file body: {written_1}"
);
// ── Save 2: user re-opens (reads written_1), saves again without editing
// `loaded.prompt` comes from split_frontmatter on the written file — same
// raw_body as before (the write-back preserved it byte-for-byte).
let (_, body_from_file) = buzz_persona_pkg::persona::split_frontmatter(&written_1).unwrap();
assert_eq!(
body_from_file, raw_body,
"split_frontmatter after save 1 must return the original raw_body"
);
// Compose again from the read-back body — must equal the stored system_prompt.
let recomposed = format!("{body_from_file}\n\n---\n# Team Instructions\n{instructions}");
assert_eq!(
recomposed, composed,
"save 2 precondition: recomposed must equal stored system_prompt so == holds"
);
// Now simulate save 2: system_prompt is the recomposed value (no trim).
p.system_prompt = recomposed;
let written_2 =
rewrite_persona_md(&written_1, &p, body_from_file, Some(instructions)).unwrap();
// Short-circuit must fire again: body still preserved, no corruption.
assert!(
written_2.ends_with("You are Paul.\n"),
"save 2: body must still be preserved by == short-circuit: {written_2:?}"
);
assert!(
!written_2.contains("# Team Instructions"),
"save 2: Team Instructions must not appear after second save: {written_2}"
);
// Both writes are byte-identical — no spurious mutations.
assert_eq!(
written_1, written_2,
"save 2: file content must be identical to save 1"
);
}
}