Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default defineConfig({
name: "smoke",
testMatch: [
"**/smoke.spec.ts",
"**/removable-defaults.spec.ts",
"**/owned-agent-discovery.spec.ts",
"**/thread-head-stale-edit.spec.ts",
"**/sidebar-offcanvas-rail.spec.ts",
Expand Down
37 changes: 27 additions & 10 deletions desktop/src-tauri/src/commands/teams/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,21 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<Tea
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

// Keep the authoritative delete and its publication policy at one testable seam.
fn delete_team_and_retain(
team: &TeamRecord,
delete: impl FnOnce() -> Result<Vec<String>, String>,
retain: impl FnOnce(&str),
) -> Result<Vec<String>, String> {
let cascaded = delete()?;
// Built-in teams are device-local templates, not owner-published records.
// Removing one must not delete another device's customized copy.
if !team.is_builtin {
retain(&team.id);
}
Ok(cascaded)
}

#[tauri::command]
pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> {
use tauri::Manager;
Expand All @@ -521,16 +536,18 @@ pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> {
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?;
// delete_team_with_cascade rejects built-in teams via validate_team_deletion,
// so reaching here means this team was owner-published — tombstone it. The
// d_tag is the team id, captured before the record left the store.
tombstone_team_pending(&app, &state, &id);
// The catalog projection is a separate coordinate with its own
// retained head, so the 30176 tombstone above does not retract it.
// Without this, deleting a shared team would leave a live catalog
// entry the owner can no longer see or unshare.
pending::tombstone_team_catalog_pending(&app, &state, &id);
let team = load_teams(&app)?
.into_iter()
.find(|team| team.id == id)
.ok_or_else(|| format!("team {id} not found"))?;
let cascaded_persona_d_tags = delete_team_and_retain(
&team,
|| delete_team_with_cascade(&app, &id),
|id| {
tombstone_team_pending(&app, &state, id);
pending::tombstone_team_catalog_pending(&app, &state, id);
},
)?;
// Tombstone the cascaded personas too, so their orphaned kind:30175 heads
// don't linger on the relay (F4). Each d-tag was captured pre-removal.
for persona_d_tag in &cascaded_persona_d_tags {
Expand Down
47 changes: 47 additions & 0 deletions desktop/src-tauri/src/commands/teams/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,53 @@ use buzz_core_pkg::kind::KIND_TEAM;
use nostr::JsonUtil;
use std::path::{Path, PathBuf};

#[test]
fn builtin_delete_is_local_only_and_durable_while_custom_delete_retains_tombstone() {
use tauri::Manager;
let dir = tempfile::tempdir().unwrap();
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
context.config_mut().identifier = dir.path().join("app").to_string_lossy().into_owned();
let app = tauri::test::mock_builder().build(context).unwrap();
assert_eq!(app.path().app_data_dir().unwrap(), dir.path().join("app"));
let keys = nostr::Keys::generate();
let db = dir.path().join("retention.db");
let conn = open_retention_db(&db).unwrap();
let mut welcome = team();
welcome.id = "builtin-team:welcome".into();
welcome.name = "Welcome Team".into();
welcome.source_dir = None;
welcome.is_builtin = true;
let mut custom = welcome.clone();
custom.id = "custom:lookalike".into();
custom.is_builtin = false;
save_teams(app.handle(), &[welcome.clone(), custom.clone()]).unwrap();
delete_team_and_retain(
&welcome,
|| delete_team_with_cascade(app.handle(), &welcome.id),
|id| tombstone_team_at(&db, &keys, id).unwrap(),
)
.unwrap();
let reloaded = load_teams(app.handle()).unwrap();
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded[0].id, custom.id);
assert!(
get_pending_sync(&conn).unwrap().is_empty(),
"local built-in opt-out must never publish an owner deletion"
);
delete_team_and_retain(
&custom,
|| delete_team_with_cascade(app.handle(), &custom.id),
|id| tombstone_team_at(&db, &keys, id).unwrap(),
)
.unwrap();
assert!(load_teams(app.handle()).unwrap().is_empty());
assert_eq!(
get_pending_sync(&conn).unwrap().len(),
1,
"custom owner deletion still syncs"
);
}

fn team() -> TeamRecord {
TeamRecord {
id: "team-abc".to_string(),
Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
],
model: None,
runtime: None,
default_active: true,
default_active: false,
},
BuiltInPersona {
id: "builtin:honey",
Expand All @@ -57,7 +57,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
name_pool: &["Honey"],
model: None,
runtime: None,
default_active: true,
default_active: false,
},
BuiltInPersona {
id: POLLEN_PERSONA_ID,
Expand All @@ -67,7 +67,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
name_pool: &[POLLEN_DISPLAY_NAME],
model: None,
runtime: None,
default_active: true,
default_active: false,
},
];

Expand Down
59 changes: 54 additions & 5 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition {
}

#[test]
fn merge_personas_adds_missing_built_ins() {
fn merge_personas_adds_missing_built_ins_as_optional_templates() {
let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z");

assert!(changed);
Expand All @@ -53,9 +53,9 @@ fn merge_personas_adds_missing_built_ins() {
.filter(|record| record.is_active)
.map(|record| record.id.as_str())
.collect();
assert_eq!(
active_ids,
vec!["builtin:fizz", "builtin:honey", "builtin:bumble"]
assert!(
active_ids.is_empty(),
"starter templates must be explicitly added"
);
}

Expand Down Expand Up @@ -124,7 +124,10 @@ fn merge_personas_adds_fizz_and_retires_old_builtins_for_existing_store() {
.find(|record| record.id == "builtin:fizz")
.expect("fizz built-in should exist");
assert!(fizz.is_builtin);
assert!(fizz.is_active);
assert!(
!fizz.is_active,
"upgrades must not activate new starter templates"
);

let solo = records
.iter()
Expand Down Expand Up @@ -412,3 +415,49 @@ fn fizz_builtin_resolves_to_buzz_agent() {
"Fizz must resolve to buzz-agent specifically"
);
}

#[test]
fn optional_templates_survive_real_store_reload_and_keep_custom_profiles() {
use tauri::Manager;
let dir = tempfile::tempdir().unwrap();
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
// Absolute identifier confines Tauri's joined app_data_dir to this tempdir
// without mutating process environment or reaching the user's installation.
context.config_mut().identifier = dir
.path()
.join("isolated-app")
.to_string_lossy()
.into_owned();
let app = tauri::test::mock_builder().build(context).unwrap();
assert_eq!(
app.path().app_data_dir().unwrap(),
dir.path().join("isolated-app")
);
let mut personas = super::load_personas(app.handle()).unwrap();
assert!(personas.iter().all(|p| !p.is_active));
let fizz = personas
.iter_mut()
.find(|p| p.id == "builtin:fizz")
.unwrap();
fizz.is_active = true;
fizz.display_name = "My customized starter".into();
personas.push(custom_persona("custom:lookalike", "Fizz"));
super::save_personas(app.handle(), &personas).unwrap();
let mut personas = super::load_personas(app.handle()).unwrap();
let fizz = personas
.iter_mut()
.find(|p| p.id == "builtin:fizz")
.unwrap();
validate_persona_activation_change(fizz, false, false, false).unwrap();
fizz.is_active = false;
super::save_personas(app.handle(), &personas).unwrap();
for _ in 0..2 {
let reloaded = super::load_personas(app.handle()).unwrap();
let fizz = reloaded.iter().find(|p| p.id == "builtin:fizz").unwrap();
assert!(!fizz.is_active);
assert_eq!(fizz.display_name, "My customized starter");
assert!(reloaded
.iter()
.any(|p| p.id == "custom:lookalike" && p.is_active));
}
}
45 changes: 21 additions & 24 deletions desktop/src-tauri/src/managed_agents/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ fn built_in_team_order(built_ins: &[BuiltInTeam], id: &str) -> Option<usize> {
built_ins.iter().position(|team| team.id == id)
}

/// Add missing built-in teams, purge pristine retired teams, demote stale
/// Purge pristine retired 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.
Expand All @@ -92,17 +92,14 @@ fn merge_teams_impl(
) -> (Vec<TeamRecord>, bool) {
let mut changed = false;

// Seed missing built-ins / re-promote existing ones that were downgraded.
// Preserve existing built-ins, but never recreate a deleted or unselected team.
for built_in in built_in_team_records(built_ins, 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;
}
}

Expand Down Expand Up @@ -143,13 +140,20 @@ fn merge_teams_impl(
(stored, changed)
}

/// Reject deletion of built-in teams. Mirrors `validate_persona_deletion`
/// for personas — built-ins always come back via `merge_teams` on the
/// next load, so blocking the delete avoids a confusing "keeps coming
/// back" UX.
pub fn validate_team_deletion(team: &TeamRecord) -> Result<(), String> {
if team.is_builtin {
return Err("Built-in teams cannot be deleted.".to_string());
/// Reject deletion while managed instances still depend on the team.
/// Built-in status is provenance, not a deletion restriction.
pub fn validate_team_deletion(
team: &TeamRecord,
agents: &[ManagedAgentRecord],
) -> Result<(), String> {
let referencing = agents_referencing_team(agents, team);
if !referencing.is_empty() {
return Err(format!(
"Cannot delete team \"{}\": {} agent(s) still reference it ({}). Delete or reconfigure them first.",
team.name,
referencing.len(),
referencing.join(", ")
));
}
Ok(())
}
Expand Down Expand Up @@ -244,25 +248,18 @@ fn agents_referencing_team<'a>(
/// vec is empty. For catalog-adopted teams (`catalog_source` present), member
/// copies matching this publication's provenance are deactivated (re-activatable
/// on re-add), not deleted.
pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<Vec<String>, String> {
pub fn delete_team_with_cascade<R: tauri::Runtime>(
app: &AppHandle<R>,
team_id: &str,
) -> Result<Vec<String>, String> {
let mut teams = load_teams(app)?;
let team = teams
.iter()
.find(|record| record.id == team_id)
.ok_or_else(|| format!("team {team_id} not found"))?;

validate_team_deletion(team)?;

let agents = crate::managed_agents::load_managed_agents(app)?;
let referencing = agents_referencing_team(&agents, team);
if !referencing.is_empty() {
return Err(format!(
"Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \
Delete or reconfigure them first.",
referencing.len(),
referencing.join(", ")
));
}
validate_team_deletion(team, &agents)?;

let mut cascaded_persona_d_tags = Vec::new();

Expand Down
35 changes: 19 additions & 16 deletions desktop/src-tauri/src/managed_agents/teams_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ fn sort_teams_empty_is_noop() {
}

#[test]
fn merge_teams_adds_missing_built_ins() {
fn merge_teams_does_not_add_missing_built_ins() {
let synthetic = BuiltInTeam {
id: "builtin-team:test",
name: "Test Team",
Expand All @@ -67,10 +67,8 @@ fn merge_teams_adds_missing_built_ins() {
let (records, changed) =
merge_teams_impl(&[synthetic], &[], Vec::new(), "2026-05-07T00:00:00Z");

assert!(changed);
assert_eq!(records.len(), 1);
assert!(records.iter().all(|r| r.is_builtin));
assert_eq!(records[0].id, "builtin-team:test");
assert!(!changed);
assert!(records.is_empty());
}

#[test]
Expand Down Expand Up @@ -111,7 +109,7 @@ fn merge_teams_preserves_unrelated_user_teams() {
merge_teams_impl(&[synthetic], &[], vec![user_team], "2026-05-07T00:00:00Z");

assert!(records.iter().any(|t| t.id == "user-uuid"));
assert!(records.iter().any(|t| t.id == "builtin-team:test"));
assert!(!records.iter().any(|t| t.id == "builtin-team:test"));
}

#[test]
Expand Down Expand Up @@ -155,12 +153,11 @@ fn merge_teams_repromotes_existing_builtin_marked_as_custom() {
}

#[test]
fn validate_team_deletion_rejects_built_ins() {
fn validate_team_deletion_allows_built_ins() {
let mut built_in = team("builtin-team:fizz", "Fizz");
built_in.is_builtin = true;

let err = validate_team_deletion(&built_in).unwrap_err();
assert_eq!(err, "Built-in teams cannot be deleted.");
assert!(validate_team_deletion(&built_in, &[]).is_ok());
}

// ── agents_referencing_team ─────────────────────────────────────────────
Expand Down Expand Up @@ -331,10 +328,17 @@ fn migration_customized_fizz_is_demoted_to_user_team() {
}

#[test]
fn welcome_team_is_seeded_and_idempotent() {
let (records, changed) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
fn welcome_team_is_optional_and_empty_store_stays_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("teams.json");
assert!(load_teams_readonly(&path).unwrap().is_empty());
std::fs::write(&path, b"[]").unwrap();
assert!(load_teams_readonly(&path).unwrap().is_empty());
}

assert!(changed);
#[test]
fn welcome_team_template_is_preserved_and_idempotent() {
let records = super::built_in_team_records(super::BUILT_IN_TEAMS, "2026-07-01T00:00:00Z");
assert_eq!(records.len(), 1);
let welcome = &records[0];
assert_eq!(welcome.id, "builtin-team:welcome");
Expand Down Expand Up @@ -364,7 +368,7 @@ fn welcome_team_is_seeded_and_idempotent() {

#[test]
fn welcome_team_seed_does_not_overwrite_customization() {
let (mut records, _) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
let mut records = super::built_in_team_records(super::BUILT_IN_TEAMS, "2026-07-01T00:00:00Z");
let welcome = records
.iter_mut()
.find(|team| team.id == "builtin-team:welcome")
Expand Down Expand Up @@ -401,9 +405,8 @@ fn load_teams_readonly_absent_file_performs_no_write() {

let records = load_teams_readonly(&path).unwrap();

// Returns the merged built-in list without persisting it.
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "builtin-team:welcome");
// A fresh store has no selected teams, and reads do not opt in.
assert!(records.is_empty());

// The file must still NOT exist — no write-on-load side effect.
assert!(
Expand Down
Loading