From 281d730af48f15aef8e20a15eba70773e7257e33 Mon Sep 17 00:00:00 2001 From: Fizz <550f2cb2562fc05f87c4839dc3c9b1bfca4d68db4183f8b54d3d477f6836a91d@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 12:55:49 -0600 Subject: [PATCH] fix(desktop): allow a team to have zero agents so it can be deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an agent team was impossible without a workaround. Three guards formed a cycle: - `delete_team` refuses while agents still reference the team ("Delete or reconfigure them first") - `validate_persona_deletion` refuses to delete a team's agent ("Delete the team to remove all team agents together") - and the team editor's submit button required at least one selected member, so the roster could not be emptied either Delete-team said "remove the agents first", delete-agent said "delete the team first", and emptying the roster — the one sequence that breaks the cycle — was blocked by the UI. The only escape was to reassign a member to another team to drain the original. The backend already supported zero-member teams: `update_team` has no minimum-member check, `apply_team_membership_delta` detaches the removed members' instances (clearing the `team_id` that `delete_team` keys on), and `persona_ids: []` is representable in `teams.json`. Only the frontend enforced the minimum, so this drops that one condition — extracted as `canSubmitTeamDialog` so the contract is unit-testable. Removing the minimum makes an empty team reachable, which would have silently exposed Deploy/Duplicate/Share on a team with nothing to deploy. Sharing one would mint a snapshot that Buzz's own importer rejects ("Team snapshot must have at least one member" — snapshot import keeps its minimum, correctly). Those three actions are now gated on `resolution.isUsable`, which was already false for an empty roster, and the team card explains the state. Edit and Delete stay enabled: emptying a team and then deleting it is the intended way out. Tests: an e2e walk of the full user sequence (edit → deselect every member → save → delete), which fails on the disabled Save button without this change; a Rust test pinning that clearing `team_id` really drops the delete guard; and unit coverage for the submit gate and the emptied-team resolution. Signed-off-by: Fizz <550f2cb2562fc05f87c4839dc3c9b1bfca4d68db4183f8b54d3d477f6836a91d@buzz.block.builderlab.xyz> Co-authored-by: Storme Briscoe Signed-off-by: Storme Briscoe --- .../src/managed_agents/teams_tests.rs | 31 ++++++++ .../features/agents/lib/teamPersonas.test.mjs | 17 +++++ .../features/agents/ui/TeamDeleteDialog.tsx | 2 +- desktop/src/features/agents/ui/TeamDialog.tsx | 7 +- .../src/features/agents/ui/TeamsSection.tsx | 21 ++++- .../agents/ui/teamDialogSelection.test.mjs | 20 +++++ .../features/agents/ui/teamDialogSelection.ts | 25 ++++++ desktop/tests/e2e/agents.spec.ts | 76 +++++++++++++++++++ 8 files changed, 190 insertions(+), 9 deletions(-) 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