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
+ This team has no agents. Add one to deploy or share it, or + delete the team. +
+ ) : null} ); })} diff --git a/desktop/src/features/agents/ui/teamDialogSelection.test.mjs b/desktop/src/features/agents/ui/teamDialogSelection.test.mjs index 6beb71ae96..e7d0ede493 100644 --- a/desktop/src/features/agents/ui/teamDialogSelection.test.mjs +++ b/desktop/src/features/agents/ui/teamDialogSelection.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canSubmitTeamDialog, copySelectedPersonaIds, countMissingPersonaIds, filterAvailablePersonaIds, @@ -90,3 +91,22 @@ test("orderPersonasByInitiallySelected keeps initially selected personas at top" ], ); }); + +// ── canSubmitTeamDialog ─────────────────────────────────────────────────── +// +// The regression these cover: the submit button used to also require +// `selectedPersonaIds.length > 0`, which made "remove every member" unsavable +// and left the team/agent delete deadlock with no way out. + +test("canSubmitTeamDialog allows saving a team with an empty roster", () => { + assert.equal(canSubmitTeamDialog({ name: "Hive", isPending: false }), true); +}); + +test("canSubmitTeamDialog still requires a non-blank name", () => { + assert.equal(canSubmitTeamDialog({ name: "", isPending: false }), false); + assert.equal(canSubmitTeamDialog({ name: " ", isPending: false }), false); +}); + +test("canSubmitTeamDialog blocks while a save is in flight", () => { + assert.equal(canSubmitTeamDialog({ name: "Hive", isPending: true }), false); +}); diff --git a/desktop/src/features/agents/ui/teamDialogSelection.ts b/desktop/src/features/agents/ui/teamDialogSelection.ts index 74e9daa62a..b13ae534be 100644 --- a/desktop/src/features/agents/ui/teamDialogSelection.ts +++ b/desktop/src/features/agents/ui/teamDialogSelection.ts @@ -8,6 +8,31 @@ export function copySelectedPersonaIds(personaIds: string[]): string[] { return [...personaIds]; } +/** + * Whether the team dialog's submit button should be enabled. + * + * A team name is required; an **empty roster is deliberately allowed**. Emptying + * a team is the only way out of the delete deadlock — `delete_team` refuses + * while agents still reference the team, and agent deletion refuses because the + * agent belongs to a team. "Remove every member, then delete the team" is the + * escape hatch, so the editor must be able to save a zero-member team. The Rust + * side already supports it (`update_team` has no minimum-member check and + * `apply_team_membership_delta` detaches the removed members' instances); the + * min-1 rule that remains applies to snapshot *import* only. + * + * Extracted as a pure function so the "empty roster is submittable" contract is + * covered by a test rather than living in a JSX `disabled` expression. + */ +export function canSubmitTeamDialog({ + name, + isPending, +}: { + name: string; + isPending: boolean; +}): boolean { + return name.trim().length > 0 && !isPending; +} + export function countMissingPersonaIds( personaIds: string[], personas: AgentPersona[], diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b81c5889f3..23a400a7d1 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2708,3 +2708,79 @@ test("duplicate instances move from the agents gallery into the agent profile", page.getByTestId(`user-profile-agent-delete-${additionalPubkey}`), ).toHaveCount(0); }); + +// Regression for the team-deletion deadlock: `delete_team` refuses while agents +// still reference the team, and deleting a team's agent refuses because it +// belongs to a team. The only non-circular way out is to empty the roster and +// then delete the team — which the editor used to forbid, because the submit +// button also required at least one selected member. This walks the full user +// sequence: edit → deselect every member → save → delete. +test("a team can be emptied and then deleted", async ({ page }) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:deadlock-a", + displayName: "Deadlock A", + systemPrompt: "First member of the team under test.", + }, + { + id: "custom:deadlock-b", + displayName: "Deadlock B", + systemPrompt: "Second member of the team under test.", + }, + ], + teams: [ + { + id: "team-deadlock", + name: "Deadlock Team", + personaIds: ["custom:deadlock-a", "custom:deadlock-b"], + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const teamCard = page.getByTestId("team-card-team-deadlock"); + await expect(teamCard).toBeVisible(); + + // Empty the roster via the edit dialog. + await page.getByLabel("Deadlock Team team actions").click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + + const roster = page.getByRole("listbox", { name: "Agents" }); + await roster.getByRole("option", { name: /Deadlock A/ }).click(); + await roster.getByRole("option", { name: /Deadlock B/ }).click(); + + // The regression: with no members selected, save must still be enabled. + const save = page.getByRole("button", { name: "Save changes" }); + await expect(save).toBeEnabled(); + await save.click(); + + // Removing members prompts about the underlying agents; keep them. + await page.getByRole("button", { name: "Keep agents" }).click(); + + // The team survives with an empty roster and says so. + await expect(teamCard).toContainText("This team has no agents"); + const teams = await invokeTauri