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({
+ 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