diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 324e13e68a5..15247fcf20b 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -124,6 +124,7 @@ pub use relay_members::*;
pub use relay_reconnect::*;
pub use social::*;
pub use team_snapshot::*;
+pub(crate) use teams::replay_pending_team_membership;
pub use teams::*;
pub use updater::*;
pub use window_chrome::*;
diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs
index b4438b67b7a..e7aaa08a385 100644
--- a/desktop/src-tauri/src/commands/personas/inbound.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound.rs
@@ -219,6 +219,10 @@ fn reconcile_inbound_persona_event_blocking(
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
+ // A prior create, update, or inbound team write can have reached teams.json
+ // before its agent-store write failed. Replay it before accepting any next
+ // inbound event, so an identical retained event cannot consume that intent.
+ crate::commands::replay_pending_team_membership(&app)?;
// Resolve inbound vs. any pending local edit before touching the store, in
// the scope the event ARRIVED on. A workspace switch since arrival leaves
@@ -305,9 +309,11 @@ fn reconcile_inbound_persona_event_blocking(
&mut teams,
d_tag,
team_content_from_event(&event)?,
+ |pending| crate::commands::teams::save_pending_team_membership(&app, pending),
|teams| save_teams(&app, teams),
|| load_managed_agents(&app),
|records| save_managed_agents(&app, records),
+ || crate::commands::teams::clear_pending_team_membership(&app),
)
})?;
if outcome == InboundOutcome::Skipped {
@@ -754,29 +760,23 @@ fn apply_inbound_managed_agent(
false
}
-/// In-memory core of the inbound `KIND_TEAM` reconcile: capture the matched
-/// team's roster *before* applying the inbound projection, apply it, persist
-/// teams authoritatively, then propagate the prior→current membership delta to
-/// live instances best-effort — the same binding semantics the local
-/// create/update commands use. Without this, a 30176 team edit from another
-/// device lands on `teams.json` but never touches `ManagedAgentRecord.team_id`:
-/// an added persona's running instances stay unbound (member in roster, not in
-/// behavior) and a removed persona's instances keep drawing the old team's
-/// instructions at spawn until restart.
-///
-/// A no-match insert has no prior roster, so its whole roster is the added
-/// delta — symmetric with `commit_team_create`. Injected persistence keeps it
-/// `AppHandle`-free so the prior-roster capture and delta direction are
-/// unit-testable; a `persist_teams` error propagates, agent IO is best-effort
-/// (mirrors the local command path: the authoritative team write already
-/// landed, and boot repair is the designed retry for a stale binding).
+/// In-memory core of the inbound `KIND_TEAM` reconcile. It stages a changed
+/// roster before the team write, then commits the team and agent stores. It
+/// clears the stage only after both writes succeed. This makes an agent-store
+/// failure retryable when the team contains a persona shared by another team.
+/// The caller advances relay retention only after this function succeeds.
+#[allow(clippy::too_many_arguments)]
fn commit_inbound_team(
teams: &mut Vec,
d_tag: String,
inbound: TeamEventContent,
+ save_pending: impl FnOnce(
+ &crate::commands::teams::PendingTeamMembershipUpdate,
+ ) -> Result<(), String>,
persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>,
load_agents: impl FnOnce() -> Result, String>,
save_agents: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>,
+ clear_pending: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let team_id = d_tag.clone();
let previous_persona_ids = teams
@@ -790,14 +790,33 @@ fn commit_inbound_team(
.find(|record| record.id == team_id)
.map(|record| record.persona_ids.clone())
.unwrap_or_default();
- persist_teams(teams)?;
- crate::commands::teams::propagate_membership_best_effort(
- &team_id,
- &previous_persona_ids,
- ¤t_persona_ids,
- load_agents,
- save_agents,
- );
+ let membership_changed = previous_persona_ids != current_persona_ids;
+ if membership_changed {
+ save_pending(&crate::commands::teams::PendingTeamMembershipUpdate {
+ team_id: team_id.clone(),
+ previous_persona_ids: previous_persona_ids.clone(),
+ current_persona_ids: current_persona_ids.clone(),
+ })?;
+ persist_teams(teams)?;
+ crate::commands::teams::propagate_membership(
+ &team_id,
+ &previous_persona_ids,
+ ¤t_persona_ids,
+ load_agents,
+ save_agents,
+ )
+ .map_err(|error| format!("could not update inbound team agents: {error}"))?;
+ clear_pending()?;
+ } else {
+ persist_teams(teams)?;
+ crate::commands::teams::propagate_membership_best_effort(
+ &team_id,
+ &previous_persona_ids,
+ ¤t_persona_ids,
+ load_agents,
+ save_agents,
+ );
+ }
Ok(())
}
diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
index e90df637314..170d0cfb204 100644
--- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
@@ -597,11 +597,13 @@ fn inbound_team_add_binds_unbound_instance_through_wiring() {
persona_ids: Some(vec!["p-existing".to_string(), "p-added".to_string()]),
},
|_| Ok(()),
+ |_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
+ || Ok(()),
)
.expect("inbound add succeeds");
@@ -641,11 +643,13 @@ fn inbound_team_removal_detaches_instance_through_wiring() {
persona_ids: Some(vec![]),
},
|_| Ok(()),
+ |_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
+ || Ok(()),
)
.expect("inbound removal succeeds");
@@ -674,11 +678,13 @@ fn inbound_team_omitted_roster_leaves_bindings_untouched() {
TEAM_ID.to_string(),
team_content_omitting_optional_fields("Renamed"),
|_| Ok(()),
+ |_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
+ || Ok(()),
)
.expect("inbound metadata-only edit succeeds");
@@ -688,15 +694,24 @@ fn inbound_team_omitted_roster_leaves_bindings_untouched() {
);
}
-/// A failing agent-store write after the authoritative `save_teams` is
-/// swallowed: the inbound reconcile still succeeds (boot repair is the retry),
-/// so a secondary-store hiccup never aborts an inbound event whose team write
-/// already landed.
+/// An inbound add persists a recovery stage before the team write. A failed
+/// agent write leaves that stage available to bind a shared persona on restart.
+/// The error stops `commit_inbound_with_store`, so the retention head does not
+/// advance until the binding becomes durable.
#[test]
-fn inbound_team_swallows_agent_store_failure() {
- let mut teams = vec![local_team()];
+fn inbound_team_failure_keeps_a_durable_shared_persona_replay_delta() {
+ let pending_file = tempfile::NamedTempFile::new().expect("temporary stage");
+ std::fs::write(pending_file.path(), "null").expect("initialize stage");
+ let mut teams = vec![local_team(), {
+ let mut other = local_team();
+ other.id = "other-team".to_string();
+ other.persona_ids = vec!["p-added".to_string()];
+ other
+ }];
teams[0].persona_ids = vec![];
- commit_inbound_team(
+ let agents = RefCell::new(vec![team_instance('a', "p-added", None)]);
+
+ let error = commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
@@ -705,11 +720,45 @@ fn inbound_team_swallows_agent_store_failure() {
instructions: None,
persona_ids: Some(vec!["p-added".to_string()]),
},
+ |pending| {
+ crate::commands::teams::save_pending_team_membership_at(
+ pending_file.path(),
+ Some(pending),
+ )
+ },
|_| Ok(()),
- || Err("agent store unreadable".to_string()),
- |_| Ok(()),
+ || Ok(agents.borrow().clone()),
+ |_| Err("agent store unwritable".to_string()),
+ || crate::commands::teams::save_pending_team_membership_at(pending_file.path(), None),
)
- .expect("inbound reconcile swallows secondary-store failure");
+ .expect_err("inbound reconcile must report a lost membership binding");
+ assert!(
+ error.contains("could not update inbound team agents"),
+ "{error}"
+ );
+ assert_eq!(
+ teams[0].persona_ids,
+ vec!["p-added"],
+ "the team write landed"
+ );
+
+ let pending = crate::commands::teams::load_pending_team_membership_at(pending_file.path())
+ .expect("read staged delta")
+ .expect("the failed inbound add keeps its stage");
+ crate::commands::teams::propagate_membership(
+ &pending.team_id,
+ &pending.previous_persona_ids,
+ &pending.current_persona_ids,
+ || Ok(agents.borrow().clone()),
+ |records| {
+ *agents.borrow_mut() = records.to_vec();
+ Ok(())
+ },
+ )
+ .expect("a restart replay binds the explicit inbound add");
+ crate::commands::teams::save_pending_team_membership_at(pending_file.path(), None)
+ .expect("clear replayed stage");
+ assert_eq!(agents.borrow()[0].team_id.as_deref(), Some(TEAM_ID));
}
/// A `persist_teams` error propagates — the authoritative team write failing is
@@ -721,9 +770,11 @@ fn inbound_team_propagates_persist_teams_error() {
&mut teams,
TEAM_ID.to_string(),
team_content("Team"),
+ |_| Ok(()),
|_| Err("disk full".to_string()),
|| Ok(vec![]),
|_| Ok(()),
+ || Ok(()),
)
.expect_err("a failed team persist must propagate");
assert_eq!(err, "disk full");
diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs
index ac43a4719ab..789bc41d859 100644
--- a/desktop/src-tauri/src/commands/personas/mod.rs
+++ b/desktop/src-tauri/src/commands/personas/mod.rs
@@ -4,7 +4,7 @@ use crate::{
app_state::AppState,
managed_agents::{
current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams,
- save_managed_agents, save_personas, stop_managed_agent_process,
+ save_managed_agents, save_personas, source_team_exists, stop_managed_agent_process,
sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change,
validate_persona_deletion, AgentDefinition, ManagedAgentRecord,
},
@@ -165,12 +165,14 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
.iter()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
- let referenced_by_team = load_teams(&app)?.iter().any(|team| {
+ let teams = load_teams(&app)?;
+ let referenced_by_team = teams.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
- validate_persona_deletion(persona, referenced_by_team)?;
+ let source_team_exists = source_team_exists(persona, &teams);
+ validate_persona_deletion(persona, referenced_by_team, source_team_exists)?;
// Capture the coordinate before the record might leave the list. Only
// reached for non-builtin, non-team personas (both rejected above),
// so every deleted persona here is one this owner published.
diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs
index 9c57ce12b53..651d2e2202c 100644
--- a/desktop/src-tauri/src/commands/team_snapshot.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot.rs
@@ -33,6 +33,10 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
const ZIP_MAGIC_PREFIX: [u8; 2] = [0x50, 0x4b];
const LEGACY_TEAM_ERROR: &str =
"Legacy team files are no longer supported. Export a buzz-team-snapshot v1 .team.json or .team.png instead.";
+/// Refusal for an export of a team that has no members. The importer rejects
+/// such a snapshot, so the producer must not write one.
+pub(crate) const EMPTY_TEAM_EXPORT_ERROR: &str =
+ "This team has no agents. Add at least one agent before you share or export it.";
/// Decode a canonical team snapshot, rejecting retired flat team JSON and
/// persona-pack ZIP files with a migration-oriented error.
@@ -272,6 +276,13 @@ struct MintedMember {
effective_avatar: Option,
}
+/// Builds the snapshot for an export.
+///
+/// A team with no members must not become a snapshot. The importer rejects such
+/// an artifact, because `validate_team_snapshot` needs one member or more. Both
+/// export commands come through this function, so the rejection belongs here.
+/// A disabled menu item is not sufficient: the commands stay callable, and a
+/// share dialog can submit after the roster becomes empty.
fn build_team_export_snapshot(
team: &TeamRecord,
personas: &[AgentDefinition],
@@ -279,6 +290,10 @@ fn build_team_export_snapshot(
memory_level: MemoryLevel,
memory_entries_by_persona: &std::collections::HashMap>,
) -> Result {
+ if team.persona_ids.is_empty() {
+ return Err(EMPTY_TEAM_EXPORT_ERROR.to_string());
+ }
+
let members = team
.persona_ids
.iter()
diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs
index 13c7f6ae810..1b8c074af6e 100644
--- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs
+++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs
@@ -304,6 +304,45 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
assert!(snap_no_instance.members[0].memory.entries.is_empty());
}
+/// The producer must refuse a team that has no members.
+///
+/// The importer rejects such a snapshot, so an export of it makes a file that
+/// nobody can import. `managed_agents::team_snapshot` pins that rejection in
+/// `validate_rejects_zero_members`. A disabled menu item does not stop the
+/// export: both export commands stay callable, and they read the team from disk
+/// at the time of the call. This test holds the guard in the producer, where
+/// both commands pass.
+#[test]
+fn empty_team_export_is_refused_before_any_bytes() {
+ let team = TeamRecord {
+ id: "empty".to_string(),
+ name: "Emptied Team".to_string(),
+ description: None,
+ instructions: None,
+ persona_ids: vec![],
+ is_builtin: false,
+ shared: false,
+ catalog_source: None,
+ source_dir: None,
+ is_symlink: false,
+ symlink_target: None,
+ version: None,
+ created_at: "now".to_string(),
+ updated_at: "now".to_string(),
+ };
+
+ let err = build_team_export_snapshot(
+ &team,
+ &[],
+ &[],
+ MemoryLevel::None,
+ &std::collections::HashMap::new(),
+ )
+ .expect_err("an export of a team with no members must fail");
+
+ assert_eq!(err, EMPTY_TEAM_EXPORT_ERROR);
+}
+
#[test]
fn team_import_definitions_are_built_for_all_members() {
let mut memory_bearing = member("Alice");
diff --git a/desktop/src-tauri/src/commands/teams/mod.rs b/desktop/src-tauri/src/commands/teams/mod.rs
index 208ac3a7117..36192bcece3 100644
--- a/desktop/src-tauri/src/commands/teams/mod.rs
+++ b/desktop/src-tauri/src/commands/teams/mod.rs
@@ -1,3 +1,6 @@
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use uuid::Uuid;
@@ -26,22 +29,218 @@ fn trim_optional(value: Option) -> Option {
})
}
-/// Propagate a team's membership *change* to its members' already-running
-/// instances, best-effort. Loads the agent store, applies the roster delta via
-/// [`apply_team_membership_delta`], and re-saves only when something changed;
-/// any load/save error is logged and swallowed. Called after the authoritative
-/// `save_teams` succeeds — the team already exists on disk and boot repair is
-/// the designed retry for a stale/unset binding, so a secondary-store hiccup
-/// must not fail a command whose team write already landed (a UI retry would
-/// then mint a duplicate team).
+/// A staged team membership change. The record persists before the team and
+/// agent stores change so a later save or launch can replay the original delta.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub(in crate::commands) struct PendingTeamMembershipUpdate {
+ pub(in crate::commands) team_id: String,
+ pub(in crate::commands) previous_persona_ids: Vec,
+ pub(in crate::commands) current_persona_ids: Vec,
+}
+
+fn pending_team_membership_path(app: &AppHandle) -> Result {
+ Ok(crate::managed_agents::managed_agents_base_dir(app)?.join("pending-team-membership.json"))
+}
+
+pub(in crate::commands) fn save_pending_team_membership_at(
+ path: &std::path::Path,
+ pending: Option<&PendingTeamMembershipUpdate>,
+) -> Result<(), String> {
+ let payload = serde_json::to_vec_pretty(&pending)
+ .map_err(|error| format!("failed to serialize pending team update: {error}"))?;
+ crate::managed_agents::storage::atomic_write_json(path, &payload)
+}
+
+pub(in crate::commands) fn load_pending_team_membership_at(
+ path: &std::path::Path,
+) -> Result
) : null}
+ {isEmptyTeam ? (
+
+ 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 6beb71ae965..6515ec94dc0 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,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);
+});
diff --git a/desktop/src/features/agents/ui/teamDialogSelection.ts b/desktop/src/features/agents/ui/teamDialogSelection.ts
index 74e9daa62a0..02e0417de74 100644
--- a/desktop/src/features/agents/ui/teamDialogSelection.ts
+++ b/desktop/src/features/agents/ui/teamDialogSelection.ts
@@ -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[],
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index b31504fe0a0..9abd3d1292e 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -114,6 +114,8 @@ export type MockManagedAgentSeed = {
personaId?: string | null;
/** Harness/runtime id pin; `null` = inherit from persona (native default). */
runtime?: string | null;
+ /** Team binding. Seed it to reproduce the native delete guard in a test. */
+ teamId?: string | null;
status?: RawManagedAgent["status"];
channelNames?: string[];
channelIds?: string[];
@@ -933,6 +935,9 @@ type RawManagedAgent = {
persona_id: string | null;
/** Record-level harness/runtime pin (`null` when inheriting from the persona). */
runtime: string | null;
+ /** Team this instance belongs to (`null` when unbound). The native delete
+ * guard refuses a team while an agent still carries its id. */
+ team_id: string | null;
relay_url: string;
acp_command: string;
agent_command: string;
@@ -1842,6 +1847,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
name: agent.name,
persona_id: agent.persona_id,
runtime: agent.runtime ?? null,
+ team_id: agent.team_id ?? null,
relay_url: agent.relay_url,
acp_command: agent.acp_command,
agent_command: agent.agent_command,
@@ -2403,6 +2409,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
// Native serde always emits this key (`null` when unpinned) — the bridge
// must mirror the wire shape, not omit the key.
runtime: seed.runtime ?? null,
+ team_id: seed.teamId ?? null,
relay_url: DEFAULT_RELAY_WS_URL,
acp_command: "buzz-acp",
agent_command: agentCommand,
@@ -9099,14 +9106,43 @@ async function handleUpdateTeam(args: {
team.persona_ids = [...args.input.personaIds];
team.updated_at = new Date().toISOString();
+ // Mirror the native `propagate_membership`: an instance bound to this team
+ // whose persona left the roster loses the binding. Without this, the mock
+ // keeps the binding and no e2e can reach the delete guard below.
+ const now = team.updated_at;
+ for (const agent of mockManagedAgents) {
+ if (agent.team_id !== team.id) continue;
+ if (agent.persona_id && team.persona_ids.includes(agent.persona_id)) {
+ continue;
+ }
+ agent.team_id = null;
+ agent.updated_at = now;
+ }
+
return { ...team, persona_ids: [...team.persona_ids] };
}
+/** Mirrors the native `agents_referencing_team` guard: names of the managed
+ * agents that still carry this team's id. */
+function mockAgentsReferencingTeam(teamId: string): string[] {
+ return mockManagedAgents
+ .filter((agent) => agent.team_id === teamId)
+ .map((agent) => agent.name);
+}
+
async function handleDeleteTeam(args: { id: string }): Promise {
const team = mockTeams.find((candidate) => candidate.id === args.id);
if (team?.is_builtin) {
throw new Error("Built-in teams cannot be deleted.");
}
+ // Mirror `delete_team_with_cascade`: a bound agent blocks the delete. This is
+ // the contract the empty-team feature depends on, so the mock must hold it.
+ const referencing = mockAgentsReferencingTeam(args.id);
+ if (referencing.length > 0) {
+ throw new Error(
+ `Cannot delete team "${args.id}": ${referencing.length} agent(s) still reference it (${referencing.join(", ")}). Delete or reconfigure them first.`,
+ );
+ }
mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id);
}
@@ -9288,10 +9324,15 @@ async function handleAddTeamFromCatalog(args: {
return { team: cloneMockTeam(team), alreadyPresent: false };
}
-async function handleExportTeamToJson(args: { id: string }): Promise {
- const team = mockTeams.find((candidate) => candidate.id === args.id);
+function getMockTeamForExport(id: string): RawTeam {
+ const team = mockTeams.find((candidate) => candidate.id === id);
if (!team) {
- throw new Error(`Team ${args.id} not found.`);
+ throw new Error(`Team ${id} not found.`);
+ }
+ if (team.persona_ids.length === 0) {
+ throw new Error(
+ "This team has no agents. Add at least one agent before you share or export it.",
+ );
}
const missingPersonaIds = team.persona_ids.filter(
@@ -9304,6 +9345,11 @@ async function handleExportTeamToJson(args: { id: string }): Promise {
);
}
+ return team;
+}
+
+async function handleExportTeamToJson(args: { id: string }): Promise {
+ getMockTeamForExport(args.id);
return true;
}
@@ -9372,6 +9418,7 @@ async function handleCreateManagedAgent(
input: {
name: string;
personaId?: string;
+ teamId?: string | null;
relayUrl?: string;
acpCommand?: string;
agentCommand?: string;
@@ -9450,6 +9497,7 @@ async function handleCreateManagedAgent(
persona_id: args.input.personaId ?? null,
// Create never pins a harness id — the record inherits from the persona.
runtime: null,
+ team_id: args.input.teamId ?? null,
relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL,
acp_command: args.input.acpCommand ?? "buzz-acp",
agent_command: agentCommand,
@@ -13627,9 +13675,16 @@ export function maybeInstallE2eTauriMocks() {
return importResult;
}
case "export_team_snapshot":
- // Mimics the save-to-disk path: report success without a real dialog.
- return true;
+ return handleExportTeamToJson(
+ payload as Parameters[0],
+ );
case "encode_team_snapshot_for_send": {
+ const input = payload as {
+ id: string;
+ memoryLevel: "none" | "core" | "everything";
+ format: "json" | "png";
+ };
+ getMockTeamForExport(input.id);
// Return a minimal PNG-shaped payload so the send flow can proceed
// through upload_media_bytes without a real Rust encode step.
const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0;
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index 76f717b8f72..0e6c3afc33f 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -2769,3 +2769,104 @@ 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.
+//
+// The team here has a bound managed agent, so the test also crosses the native
+// delete guard: the delete must fail while the agent carries the team id, and it
+// must succeed after the save clears the binding.
+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"],
+ },
+ ],
+ managedAgents: [
+ {
+ pubkey: "de".repeat(32),
+ name: "Deadlock Instance",
+ personaId: "custom:deadlock-a",
+ teamId: "team-deadlock",
+ status: "stopped",
+ },
+ ],
+ });
+ await gotoApp(page);
+ await page.getByTestId("open-agents-view").click();
+
+ const teamCard = page.getByTestId("team-card-team-deadlock");
+ await expect(teamCard).toBeVisible();
+
+ // The starting state: the agent is bound, so the delete guard refuses.
+ const blocked = await invokeTauriExpectError(page, "delete_team", {
+ id: "team-deadlock",
+ });
+ expect(blocked).toContain("still reference it (Deadlock Instance)");
+
+ // 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>(
+ page,
+ "list_teams",
+ );
+ expect(
+ teams.find((team) => team.id === "team-deadlock")?.persona_ids,
+ ).toEqual([]);
+
+ // The save also cleared the binding. This is what the delete guard reads, and
+ // it is the state that the save must guarantee before it reports success.
+ const agents = await invokeTauri<
+ Array<{ pubkey: string; team_id: string | null }>
+ >(page, "list_managed_agents");
+ expect(
+ agents.find((agent) => agent.pubkey === "de".repeat(32))?.team_id,
+ ).toBe(null);
+
+ // 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>(
+ page,
+ "list_teams",
+ );
+ expect(remaining.some((team) => team.id === "team-deadlock")).toBe(false);
+});
diff --git a/desktop/tests/e2e/team-snapshot.spec.ts b/desktop/tests/e2e/team-snapshot.spec.ts
index 6246c7ac8c0..1dbda83c9a0 100644
--- a/desktop/tests/e2e/team-snapshot.spec.ts
+++ b/desktop/tests/e2e/team-snapshot.spec.ts
@@ -23,6 +23,35 @@ async function readCommandLog(page: import("@playwright/test").Page) {
});
}
+async function invokeTauriExpectError(
+ page: import("@playwright/test").Page,
+ command: string,
+ payload?: Record,
+) {
+ return page.evaluate(
+ async ({ targetCommand, targetPayload }) => {
+ const invoke = (
+ window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ }
+ ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
+ if (!invoke) {
+ throw new Error("Mock invoke bridge is unavailable.");
+ }
+ try {
+ await invoke(targetCommand, targetPayload);
+ return null;
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+ },
+ { targetCommand: command, targetPayload: payload },
+ );
+}
+
async function gotoAgentsPage(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
@@ -49,6 +78,40 @@ 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],
+};
+
+const EMPTY_TEAM_EXPORT_ERROR =
+ "This team has no agents. Add at least one agent before you share or export it.";
+
+test("empty teams cannot export or share snapshots through the mock bridge", async ({
+ page,
+}) => {
+ await installMockBridge(page);
+ await gotoAgentsPage(page);
+
+ for (const command of [
+ "export_team_to_json",
+ "export_team_snapshot",
+ "encode_team_snapshot_for_send",
+ ]) {
+ const error = await invokeTauriExpectError(page, command, {
+ id: "team-engineering-001",
+ format: "png",
+ memoryLevel: "none",
+ });
+ expect(error).toBe(EMPTY_TEAM_EXPORT_ERROR);
+ }
+});
+
// ── (a) Confirm-fail + retry ────────────────────────────────────────────────
test("team_snapshot_import_confirm_fail_renders_error_and_retry_succeeds", async ({
@@ -251,16 +314,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");
@@ -289,9 +353,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(
@@ -307,7 +373,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(";
});
@@ -332,11 +398,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();
@@ -397,12 +464,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" }),
@@ -508,24 +576,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"),