Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
27 changes: 27 additions & 0 deletions desktop/src-tauri/src/managed_agents/teams_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,33 @@ fn agents_referencing_team_empty_when_no_matches() {
assert!(agents_referencing_team(&agents, &t).is_empty());
}

/// A detached agent must not stop the deletion of a team.
///
/// To delete a team, you must first remove each member. That clears `team_id`
/// on the agent. This test pins the result: the guard then finds no agents.
#[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]
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/features/agents/lib/teamPersonas.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,20 @@ test("getUsableTeams keeps only fully-resolved teams with at least one persona",
["team-ready"],
);
});

// An emptied team is now possible in the editor. You remove all the members to
// make it possible to delete the team. But the team must still be unusable. A
// snapshot of it has no members, and the import of that snapshot fails with the
// message "Team snapshot must have at least one member". A deployment of it
// would also add no agents.
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, []);
});
2 changes: 1 addition & 1 deletion desktop/src/features/agents/ui/TeamDeleteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function TeamDeleteDialog({
<AlertDialogTitle>Delete team?</AlertDialogTitle>
<AlertDialogDescription>
{team
? `Delete "${team.name}". Already-deployed agents are not affected, but this team template will no longer be available.`
? `Delete "${team.name}". This deletes the team template only. Deployed agents stay in place. You cannot delete the team while an agent is still a member of it.`
: "Delete this team."}
</AlertDialogDescription>
</AlertDialogHeader>
Expand Down
7 changes: 2 additions & 5 deletions desktop/src/features/agents/ui/TeamDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Textarea } from "@/shared/ui/textarea";
import { personaCatalogCopy } from "./personaLibraryCopy";
import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog";
import {
canSubmitTeamDialog,
copySelectedPersonaIds,
countMissingPersonaIds,
filterAvailablePersonaIds,
Expand Down Expand Up @@ -325,11 +326,7 @@ export function TeamDialog({
Cancel
</Button>
<Button
disabled={
name.trim().length === 0 ||
selectedPersonaIds.length === 0 ||
isPending
}
disabled={!canSubmitTeamDialog({ name, isPending })}
onClick={() => void handleSubmit()}
size="sm"
type="button"
Expand Down
15 changes: 12 additions & 3 deletions desktop/src/features/agents/ui/TeamsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function TeamsSection({
const resolution = resolveTeamPersonas(team, personas);
const missingPersonaCount = resolution.missingPersonaCount;
const hasMissingPersonas = resolution.hasMissingPersonas;
const isEmptyTeam = team.personaIds.length === 0;

return (
<TeamIdentityCard
Expand All @@ -107,12 +108,14 @@ export function TeamsSection({
<EllipsisVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
{/* Edit and Delete stay enabled for an unusable team on purpose. You use them
to empty a team and then delete it. */}
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
disabled={isPending || !resolution.isUsable}
onClick={() => onAddToChannel(team)}
>
<Rocket className="h-4 w-4" />
Expand All @@ -127,14 +130,14 @@ export function TeamsSection({
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
disabled={isPending || !resolution.isUsable}
onClick={() => onDuplicate(team)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending || hasMissingPersonas}
disabled={isPending || !resolution.isUsable}
onClick={() => onShare(team)}
>
<Share2 className="h-4 w-4" />
Expand Down Expand Up @@ -171,6 +174,12 @@ export function TeamsSection({
agents. Edit the team to fix it before deploying or sharing.
</p>
) : null}
{isEmptyTeam ? (
<p className="border-t border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
This team has no agents. Add one to deploy or share it, or
delete the team.
</p>
) : null}
</TeamIdentityCard>
);
})}
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/agents/ui/teamDialogSelection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
canSubmitTeamDialog,
copySelectedPersonaIds,
countMissingPersonaIds,
filterAvailablePersonaIds,
Expand Down Expand Up @@ -90,3 +91,21 @@ test("orderPersonasByInitiallySelected keeps initially selected personas at top"
],
);
});

// ── canSubmitTeamDialog ───────────────────────────────────────────────────
//
// A team with no members must be savable. If it is not, you cannot delete a
// team, because you must first remove each member.

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);
});
16 changes: 16 additions & 0 deletions desktop/src/features/agents/ui/teamDialogSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ export function copySelectedPersonaIds(personaIds: string[]): string[] {
return [...personaIds];
}

/**
* Tells you if the submit button in the team dialog is enabled.
*
* A name is necessary. A member is not. A team with no members must be
* savable, because you must empty a team before you can delete it.
*/
export function canSubmitTeamDialog({
name,
isPending,
}: {
name: string;
isPending: boolean;
}): boolean {
return name.trim().length > 0 && !isPending;
}

export function countMissingPersonaIds(
personaIds: string[],
personas: AgentPersona[],
Expand Down
73 changes: 73 additions & 0 deletions desktop/tests/e2e/agents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2708,3 +2708,76 @@ test("duplicate instances move from the agents gallery into the agent profile",
page.getByTestId(`user-profile-agent-delete-${additionalPubkey}`),
).toHaveCount(0);
});

// You must be able to empty a team and then delete it. Before, the submit
// button needed one member or more, so neither step was possible.
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();

// This is the corrected behavior. No member is selected, and the save
// button must be enabled.
const save = page.getByRole("button", { name: "Save changes" });
await expect(save).toBeEnabled();
await save.click();

// The app asks what to do with the agents of the removed members. Keep them.
await page.getByRole("button", { name: "Keep agents" }).click();

// The team is still present, it has no members, and the card shows this.
await expect(teamCard).toContainText("This team has no agents");
const teams = await invokeTauri<Array<{ id: string; persona_ids: string[] }>>(
page,
"list_teams",
);
expect(
teams.find((team) => team.id === "team-deadlock")?.persona_ids,
).toEqual([]);

// No agent points to the team now. Thus you can delete the team.
await page.getByLabel("Deadlock Team team actions").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await page
.getByRole("button", { name: "Delete", exact: true })
.last()
.click();

await expect(teamCard).toHaveCount(0);
const remaining = await invokeTauri<Array<{ id: string }>>(
page,
"list_teams",
);
expect(remaining.some((team) => team.id === "team-deadlock")).toBe(false);
});
40 changes: 28 additions & 12 deletions desktop/tests/e2e/team-snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ const ANALYST_PERSONA_ID = "test-analyst";
const ANALYST_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";

// The share tests need a team with a member. The default mock team
// "Engineering" has none, so Share is disabled for it. Use a different name,
// or a locator matches both teams.
const SHARE_TEAM_NAME = "Delivery Crew";
const SHARE_TEAM_SEED = {
id: "team-share-001",
name: SHARE_TEAM_NAME,
description: "Team for the share tests",
personaIds: [ANALYST_PERSONA_ID],
};

// ── (a) Confirm-fail + retry ────────────────────────────────────────────────

test("team_snapshot_import_confirm_fail_renders_error_and_retry_succeeds", async ({
Expand Down Expand Up @@ -251,16 +262,17 @@ test("team sharing uses the people picker and gates memory before sending", asyn
displayName: "Charlie",
},
],
teams: [SHARE_TEAM_SEED],
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
});
await gotoAgentsPage(page);

await page.getByLabel("Engineering team actions").click();
await page.getByLabel(`${SHARE_TEAM_NAME} team actions`).click();
await page.getByRole("menuitem", { name: "Share" }).click();
const shareDialog = page.getByTestId("team-share-dialog");
await expect(shareDialog).toBeVisible();
await expect(
shareDialog.getByRole("heading", { name: "Share Engineering" }),
shareDialog.getByRole("heading", { name: `Share ${SHARE_TEAM_NAME}` }),
).toBeVisible();

const search = shareDialog.getByTestId("team-share-recipient-search");
Expand Down Expand Up @@ -289,9 +301,11 @@ test("team sharing uses the people picker and gates memory before sending", asyn
expect(encodeLevelsBeforeConfirmation).toEqual([]);

await memoryConfirmation.getByTestId("team-share-memory-confirm").click();
await expect(page.getByText("Sent a copy of Engineering")).toBeVisible({
timeout: 8_000,
});
await expect(page.getByText(`Sent a copy of ${SHARE_TEAM_NAME}`)).toBeVisible(
{
timeout: 8_000,
},
);

const log = await readCommandLog(page);
expect(
Expand All @@ -307,7 +321,7 @@ test("team sharing uses the people picker and gates memory before sending", asyn
);
expect(sendEntry).toBeTruthy();
const sendPayload = sendEntry?.payload as { content?: string } | undefined;
expect(sendPayload?.content).toContain("[Engineering](");
expect(sendPayload?.content).toContain(`[${SHARE_TEAM_NAME}](`);
expect(sendPayload?.content).not.toContain("![image](");
});

Expand All @@ -332,11 +346,12 @@ test("team share level carries memories onto the link path too", async ({
},
],
agentMemory: createMockAgentMemoryListing(),
teams: [SHARE_TEAM_SEED],
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
});
await gotoAgentsPage(page);

await page.getByLabel("Engineering team actions").click();
await page.getByLabel(`${SHARE_TEAM_NAME} team actions`).click();
await page.getByRole("menuitem", { name: "Share" }).click();
const shareDialog = page.getByTestId("team-share-dialog");
await expect(shareDialog).toBeVisible();
Expand Down Expand Up @@ -397,12 +412,13 @@ test("team sharing keeps link copy and export in the shared surface", async ({
personaId: ANALYST_PERSONA_ID,
},
],
teams: [SHARE_TEAM_SEED],
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
uploadDelayMs: 800,
});
await gotoAgentsPage(page);

await page.getByLabel("Engineering team actions").click();
await page.getByLabel(`${SHARE_TEAM_NAME} team actions`).click();
const menu = page.getByRole("menu");
await expect(
menu.getByRole("menuitem", { name: "Export snapshot" }),
Expand Down Expand Up @@ -508,24 +524,24 @@ test("team sharing keeps link copy and export in the shared surface", async ({

const composerTeamCard = page.getByTestId("composer-team-snapshot-card");
await expect(composerTeamCard).toBeVisible();
await expect(composerTeamCard).toContainText("Engineering");
await expect(composerTeamCard).toContainText(SHARE_TEAM_NAME);
await expect(composerTeamCard.locator("img")).toHaveCount(0);
await page.getByTestId("send-message").click();

const sentTeamCard = page.getByTestId("agent-snapshot-card").last();
await expect(sentTeamCard).toBeVisible();
await expect(sentTeamCard).toContainText("Engineering");
await expect(sentTeamCard).toContainText(SHARE_TEAM_NAME);
await expect(sentTeamCard).toContainText("Add team");
await expect(sentTeamCard.locator("img")).toHaveCount(0);

await page.getByTestId("open-agents-view").click();
await page.getByLabel("Engineering team actions").click();
await page.getByLabel(`${SHARE_TEAM_NAME} team actions`).click();
await page.getByRole("menuitem", { name: "Share" }).click();
await page.getByTestId("team-share-export").click();
const exportDialog = page.getByTestId("team-snapshot-export-dialog");
await expect(exportDialog).toBeVisible();
await expect(
exportDialog.getByRole("heading", { name: "Export Engineering" }),
exportDialog.getByRole("heading", { name: `Export ${SHARE_TEAM_NAME}` }),
).toBeVisible();
await expect(
exportDialog.getByTestId("team-snapshot-memory-trigger"),
Expand Down
Loading