diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index ff7900d392..282cb532df 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -263,6 +263,37 @@ fn agents_referencing_team_empty_when_no_matches() { assert!(agents_referencing_team(&agents, &t).is_empty()); } +/// The escape hatch from the team/agent delete deadlock, asserted end to end at +/// this layer: `delete_team_with_cascade` refuses while any agent references the +/// team, and `validate_persona_deletion` refuses to delete a team's agent — so +/// the only non-circular sequence is "empty the team's roster, then delete the +/// team". Emptying the roster clears `team_id` on the members' instances (see +/// `apply_team_membership_delta` in `commands::teams`), and once it is cleared +/// this guard must report zero referencing agents. If it still matched, the team +/// would stay permanently undeletable and the deadlock would be back. +#[test] +fn detached_agents_no_longer_reference_the_team() { + let t = team("json-team-3", "Emptied Team"); + + let mut bound = managed_agent("Bound Agent"); + bound.team_id = Some("json-team-3".to_string()); + assert_eq!( + agents_referencing_team(std::slice::from_ref(&bound), &t), + vec!["Bound Agent"], + "a bound agent blocks team deletion" + ); + + // What emptying the roster does: clear the binding, keep the agent. + let detached = ManagedAgentRecord { + team_id: None, + ..bound + }; + assert!( + agents_referencing_team(std::slice::from_ref(&detached), &t).is_empty(), + "a detached agent must not block team deletion" + ); +} + // Migration pins — exercise the real merge_teams wrapper (with production consts). #[test] diff --git a/desktop/src/features/agents/lib/teamPersonas.test.mjs b/desktop/src/features/agents/lib/teamPersonas.test.mjs index 34d2246e98..85163e8f23 100644 --- a/desktop/src/features/agents/lib/teamPersonas.test.mjs +++ b/desktop/src/features/agents/lib/teamPersonas.test.mjs @@ -92,3 +92,20 @@ test("getUsableTeams keeps only fully-resolved teams with at least one persona", ["team-ready"], ); }); + +// An emptied team is now reachable through the editor (removing every member is +// how you escape the team/agent delete deadlock). It must still be treated as +// unusable: a zero-member snapshot fails the importer's own +// "Team snapshot must have at least one member" check, and deploying it would +// attach no agents at all. +test("resolveTeamPersonas marks a deliberately emptied team complete but unusable", () => { + const resolution = resolveTeamPersonas(createTeam("team-empty", []), [ + createPersona("persona-1", "Solo"), + ]); + + assert.equal(resolution.hasMissingPersonas, false); + assert.equal(resolution.isComplete, true); + assert.equal(resolution.isUsable, false); + assert.equal(resolution.missingPersonaCount, 0); + assert.deepEqual(resolution.resolvedPersonaIds, []); +}); diff --git a/desktop/src/features/agents/ui/TeamDeleteDialog.tsx b/desktop/src/features/agents/ui/TeamDeleteDialog.tsx index 383f92c4a5..5477fd9a02 100644 --- a/desktop/src/features/agents/ui/TeamDeleteDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDeleteDialog.tsx @@ -31,7 +31,7 @@ export function TeamDeleteDialog({ Delete team? {team - ? `Delete "${team.name}". Already-deployed agents are not affected, but this team template will no longer be available.` + ? `Delete "${team.name}". This removes the team template only — deployed agents are left in place, but deletion is blocked while any of them still reference this team.` : "Delete this team."} diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429d..e4a0128e5c 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -21,6 +21,7 @@ import { Textarea } from "@/shared/ui/textarea"; import { personaCatalogCopy } from "./personaLibraryCopy"; import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog"; import { + canSubmitTeamDialog, copySelectedPersonaIds, countMissingPersonaIds, filterAvailablePersonaIds, @@ -325,11 +326,7 @@ export function TeamDialog({ Cancel