Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export default defineConfig({
"**/harness-management.spec.ts",
"**/harness-catalog-screenshots.spec.ts",
"**/inline-custom-harness.spec.ts",
"**/huddle-transcription.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
66 changes: 55 additions & 11 deletions desktop/src-tauri/src/huddle/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
//!
//! Mental model:
//! add_agent_to_huddle → kind:9000 to ephemeral channel
//! → kind:9000 to parent channel (best-effort)
//! → preserve existing parent membership, or
//! kind:9000 to parent channel (best-effort)
//!
//! ACP spawning is NOT needed here: the running agent process auto-subscribes
//! when it receives the kind:9000 membership notification. Huddle-specific
Expand All @@ -11,7 +12,10 @@
use serde::Serialize;
use uuid::Uuid;

use crate::{app_state::AppState, events, relay::submit_event};
use crate::{
app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles,
relay::submit_event,
};

// ── Constants ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -61,8 +65,9 @@ with the next one.
/// The field exists for forward compatibility with future batch-add operations
/// where partial success may be meaningful.
///
/// `parent_added` reflects whether the parent-channel add succeeded;
/// `parent_error` carries the error string when it didn't.
/// `parent_added` reflects whether the parent already contained the agent or
/// the parent-channel add succeeded; `parent_error` carries the error string
/// when neither condition could be confirmed.
#[derive(Debug, Serialize)]
pub struct AgentAddResult {
/// Always `true` — invariant guaranteed by [`add_agent_to_huddle`].
Expand Down Expand Up @@ -91,17 +96,33 @@ pub async fn add_agent_to_huddle(
let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?;
submit_event(add_eph, state).await?;

// 2. Add agent to parent channel — so agent has full context.
// Best-effort: capture the error but don't propagate it.
let (parent_added, parent_error) = {
// 2. Preserve any active parent membership, regardless of role. Rewriting
// an existing DM member as `bot` is both unnecessary and forbidden for
// non-admins. Otherwise add the agent so it has full context.
// Best-effort: capture a real error but don't propagate it.
let parent_channel_id_string = parent_channel_id.to_string();
let parent_already_contains_agent =
fetch_channel_members_with_roles(&parent_channel_id_string, state)
.await
.is_ok_and(|members| contains_member(&members, agent_pubkey));

let (parent_added, parent_error) = if parent_already_contains_agent {
(true, None)
} else {
let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?;
match submit_event(add_parent, state).await {
Ok(_) => (true, None),
Err(e) => {
eprintln!(
"buzz-desktop: add agent to parent channel failed (may already be member): {e}"
);
(false, Some(e))
let active_after_error =
fetch_channel_members_with_roles(&parent_channel_id_string, state)
.await
.is_ok_and(|members| contains_member(&members, agent_pubkey));
if active_after_error {
(true, None)
} else {
eprintln!("buzz-desktop: add agent to parent channel failed: {e}");
(false, Some(e))
}
}
}
};
Expand All @@ -112,3 +133,26 @@ pub async fn add_agent_to_huddle(
parent_error,
})
}

fn contains_member(members: &[(String, Option<String>)], pubkey: &str) -> bool {
members
.iter()
.any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey))
}

#[cfg(test)]
mod tests {
use super::contains_member;

#[test]
fn existing_parent_membership_is_preserved_regardless_of_role() {
let members = vec![
("agent-member".to_owned(), Some("member".to_owned())),
("agent-bot".to_owned(), Some("bot".to_owned())),
];

assert!(contains_member(&members, "AGENT-MEMBER"));
assert!(contains_member(&members, "agent-bot"));
assert!(!contains_member(&members, "missing"));
}
}
Loading
Loading