diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 0eb024a86a..5f8245ae10 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1040,23 +1040,72 @@ pub async fn discover_managed_agent_prereqs( #[tauri::command] pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - // Query kind:10100 agent profile events from the relay. - let events = query_relay( + // Query kind:30177 managed-agent events — the kind agent identity/config + // is actually published under. kind:10100 is defined in buzz-core but + // nothing in the codebase publishes it; see managed_agents/agent_events.rs. + let agent_events = query_relay(&state, &[serde_json::json!({ "kinds": [30177] })]).await?; + + let mut agents: Vec = agent_events + .iter() + .filter_map(|ev| nostr_convert::relay_agent_seed_from_managed_agent_event(ev).ok()) + .collect(); + + if agents.is_empty() { + return Ok(agents); + } + + // kind:30177 doesn't carry channel membership — resolve it via kind:39002 + // `#p`, the same pattern buzz-acp's own RelayClient::discover_channels + // uses for an agent's own memberships (crates/buzz-acp/src/relay.rs). + let agent_pubkeys: std::collections::HashSet = + agents.iter().map(|a| a.pubkey.clone()).collect(); + let member_events = query_relay( &state, &[serde_json::json!({ - "kinds": [10100], + "kinds": [39002], + "#p": agent_pubkeys.iter().collect::>(), })], ) .await?; + let channel_ids_by_agent = + nostr_convert::agent_channel_ids_from_member_events(&member_events, &agent_pubkeys); - // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize - // into the strongly-typed `Vec` the frontend expects. - let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") + let all_channel_ids: Vec = channel_ids_by_agent + .values() + .flatten() .cloned() - .unwrap_or_else(|| serde_json::json!([])); - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) + .collect::>() + .into_iter() + .collect(); + + // Channel names are display-only (eligibility only needs channel_ids) — + // best-effort, same #d + limit pattern as get_channels (commands/channels.rs). + let name_by_id = if all_channel_ids.is_empty() { + std::collections::HashMap::new() + } else { + let meta_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "#d": all_channel_ids, + "limit": all_channel_ids.len(), + })], + ) + .await?; + nostr_convert::channel_names_by_id(&meta_events) + }; + + for agent in &mut agents { + if let Some(ids) = channel_ids_by_agent.get(&agent.pubkey) { + agent.channels = ids + .iter() + .filter_map(|id| name_by_id.get(id).cloned()) + .collect(); + agent.channel_ids = ids.clone(); + } + } + + Ok(agents) } #[cfg(test)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..e900b57655 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -12,6 +12,7 @@ use std::collections::{BTreeSet, HashMap}; use nostr::{Event, ToBech32}; use serde_json::{json, Value}; +use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo}; use crate::models::*; mod user_search; @@ -495,6 +496,80 @@ pub fn agents_from_events(events: &[Event]) -> Value { json!({ "agents": arr }) } +// ── kind:30177 (managed-agent) relay-agent directory ──────────────────────── + +/// Build a `RelayAgentInfo` seed from a kind:30177 managed-agent event — +/// everything except `channels`/`channel_ids`, which kind:30177 doesn't +/// carry and must be resolved separately from a kind:39002 membership query +/// (see `agent_channel_ids_from_member_events`). +/// +/// The agent's pubkey comes from the event's `d` tag, not `event.pubkey`: +/// kind:30177 is a parameterized-replaceable event published BY the agent's +/// *owner*, so `event.pubkey` is the owner's identity, not the agent's. +pub fn relay_agent_seed_from_managed_agent_event(event: &Event) -> Result { + let pubkey = first_tag_value(event, "d") + .ok_or_else(|| "kind:30177 missing required `d` tag".to_string())? + .to_string(); + let content = managed_agent_content_from_event(event)?; + Ok(RelayAgentInfo { + pubkey, + name: content.name, + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "offline".to_string(), + respond_to: Some(content.respond_to), + respond_to_allowlist: content.respond_to_allowlist, + }) +} + +/// Build a pubkey → channel-id-list map from kind:39002 (NIP-29 group +/// members) events, restricted to `agent_pubkeys`. One event is one +/// channel's full member list; the channel id is its `d` tag, and each +/// `p`-tag naming one of `agent_pubkeys` means that agent is a member. +/// +/// Mirrors the query pattern `buzz-acp`'s own `RelayClient::discover_channels` +/// uses to find an agent's own channel memberships +/// (`crates/buzz-acp/src/relay.rs`) and the pattern `get_channels` uses for +/// the viewer's own memberships (`commands/channels.rs`) — batched across +/// multiple agent pubkeys in one relay round trip instead of one pubkey. +pub fn agent_channel_ids_from_member_events( + events: &[Event], + agent_pubkeys: &std::collections::HashSet, +) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for event in events { + let Some(channel_id) = first_tag_value(event, "d") else { + continue; + }; + for slice in tags_named(event, "p") { + let Some(pubkey) = slice.get(1) else { continue }; + if !agent_pubkeys.contains(pubkey) { + continue; + } + map.entry(pubkey.clone()) + .or_default() + .push(channel_id.to_string()); + } + } + map +} + +/// Build a channel-id → name map from kind:39000 metadata events, for +/// filling in `RelayAgentInfo::channels` (display-only — eligibility logic +/// only needs `channel_ids`). +pub fn channel_names_by_id(events: &[Event]) -> HashMap { + events + .iter() + .filter_map(|event| { + let id = first_tag_value(event, "d")?.to_string(); + let name = first_tag_value(event, "name").unwrap_or("").to_string(); + Some((id, name)) + }) + .collect() +} + // ── kind:13534 (relay membership list) ────────────────────────────────────── /// Convert a kind:13534 relay membership list to the relay members format. @@ -1008,4 +1083,89 @@ mod tests { // Epoch assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); } + + // ── kind:30177 (managed-agent) relay-agent directory ──────────────── + + #[test] + fn relay_agent_seed_uses_d_tag_not_event_pubkey() { + let agent_pubkey = "1".repeat(64); + let content = serde_json::json!({ + "name": "Scout", + "parallelism": 1, + "respond_to": "anyone", + }) + .to_string(); + let e = ev(30177, &content, vec![vec!["d", &agent_pubkey]]); + + let seed = relay_agent_seed_from_managed_agent_event(&e).unwrap(); + + assert_eq!(seed.pubkey, agent_pubkey); + assert_ne!( + seed.pubkey, + e.pubkey.to_hex(), + "must not use the event author (the owner) as the agent's pubkey" + ); + assert_eq!(seed.name, "Scout"); + assert_eq!( + seed.respond_to, + Some(crate::managed_agents::RespondTo::Anyone) + ); + assert!(seed.channel_ids.is_empty()); + assert!(seed.channels.is_empty()); + } + + #[test] + fn relay_agent_seed_rejects_event_without_d_tag() { + let content = serde_json::json!({ + "name": "Scout", + "parallelism": 1, + "respond_to": "anyone", + }) + .to_string(); + let e = ev(30177, &content, vec![]); + + assert!(relay_agent_seed_from_managed_agent_event(&e).is_err()); + } + + #[test] + fn agent_channel_ids_groups_by_pubkey_and_ignores_non_candidates() { + let agent_a = "a".repeat(64); + let agent_b = "b".repeat(64); + let stranger = "c".repeat(64); + let candidates: std::collections::HashSet = + [agent_a.clone(), agent_b.clone()].into_iter().collect(); + + let general = ev( + 39002, + "", + vec![ + vec!["d", "general"], + vec!["p", &agent_a], + vec!["p", &stranger], + ], + ); + let random = ev(39002, "", vec![vec!["d", "random"], vec!["p", &agent_b]]); + + let map = agent_channel_ids_from_member_events(&[general, random], &candidates); + + assert_eq!(map.get(&agent_a), Some(&vec!["general".to_string()])); + assert_eq!(map.get(&agent_b), Some(&vec!["random".to_string()])); + assert!( + !map.contains_key(&stranger), + "non-candidate pubkeys must not appear in the result" + ); + } + + #[test] + fn channel_names_by_id_maps_d_tag_to_name_tag() { + let e = ev( + 39000, + "", + vec![vec!["d", "general"], vec!["name", "general"]], + ); + + let map = channel_names_by_id(std::slice::from_ref(&e)); + + assert_eq!(map.get("general"), Some(&"general".to_string())); + } } diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..9f2996290b 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -138,11 +138,13 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); + const mentionableAgentPubkeys = new Set(); assert.equal( isAgentIdentityInManagedList( { isAgent: false, pubkey: PUB_B }, managedAgentPubkeys, + mentionableAgentPubkeys, ), true, ); @@ -150,6 +152,7 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent isAgentIdentityInManagedList( { isAgent: true, pubkey: PUB_A.toUpperCase() }, managedAgentPubkeys, + mentionableAgentPubkeys, ), true, ); @@ -157,11 +160,42 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent isAgentIdentityInManagedList( { isAgent: true, pubkey: PUB_B }, managedAgentPubkeys, + mentionableAgentPubkeys, ), false, ); }); +test("isAgentIdentityInManagedList: admits a channel-member agent shared by another owner", () => { + const managedAgentPubkeys = new Set(); + const mentionableAgentPubkeys = new Set([PUB_B]); + + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, isMember: true, pubkey: PUB_B }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + true, + "a bot channel member whose relay policy makes them mentionable must be admitted even when not locally managed", + ); +}); + +test("isAgentIdentityInManagedList: still drops a non-member agent even if mentionable via the relay directory", () => { + const managedAgentPubkeys = new Set(); + const mentionableAgentPubkeys = new Set([PUB_B]); + + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, isMember: false, pubkey: PUB_B }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + false, + "relay-directory presence alone (no channel membership) must not admit a candidate — preserves the existing 'relay-only agents stay hidden' e2e behavior", + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..4b576525bd 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -55,13 +55,20 @@ export function getMentionableAgentPubkeys({ } export function isAgentIdentityInManagedList( - candidate: { isAgent?: boolean; pubkey: string }, + candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string }, managedAgentPubkeys: ReadonlySet, + mentionableAgentPubkeys: ReadonlySet, ) { - return ( - candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) - ); + if (candidate.isAgent !== true) return true; + const pubkey = normalizePubkey(candidate.pubkey); + if (managedAgentPubkeys.has(pubkey)) return true; + // A channel member whose relay-published policy (kind:30177, possibly + // owned by a different identity) makes them mentionable is admitted too — + // this is what lets a bot member of a shared channel be @-mentioned + // regardless of who owns/hosts it. Non-members are never admitted here: + // that's the relay-directory-only case (listed, but not actually added to + // any shared channel), which stays hidden. + return candidate.isMember === true && mentionableAgentPubkeys.has(pubkey); } export function shouldHideAgentFromMentions({ diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..629cd06bc0 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -282,7 +282,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInManagedList(candidate, managedAgentPubkeys, new Set()) ) { return; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..bd944e141f 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -246,7 +246,13 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if ( + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + mentionableAgentPubkeys, + ) + ) { return; } if ( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b15358d9d3..2df3bfc657 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2211,6 +2211,28 @@ function resetMockRelayAgents(config?: E2eConfig) { respond_to: seed.respondTo ?? "owner-only", respond_to_allowlist: seed.respondToAllowlist ?? [], }); + + // A relay-agent seed that names real channels represents a bot that is + // an actual member of those channels — e.g. another identity's agent + // added as a bot member of a shared channel — not just a + // relay-directory listing. Mirror resetMockManagedAgents's membership + // push (below) so specs can exercise the cross-owner "bot member of a + // shared channel" case without also declaring the agent as locally + // managed. + for (const channel of channels) { + if (channel.members.some((member) => member.pubkey === seed.pubkey)) { + continue; + } + channel.members.push({ + pubkey: seed.pubkey, + role: "bot", + is_agent: true, + joined_at: new Date().toISOString(), + display_name: seed.name, + }); + syncMockChannel(channel); + touchMockChannel(channel); + } } } @@ -3193,7 +3215,7 @@ function initializeMockHuddle( const openedExternalUrls: string[] = []; const defaultMockRelayAgents: RawRelayAgent[] = [ { - pubkey: ALICE_PUBKEY, + pubkey: "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", name: "alice", agent_type: "goose", channels: ["general", "agents"], @@ -3207,7 +3229,7 @@ const defaultMockRelayAgents: RawRelayAgent[] = [ respond_to_allowlist: [], }, { - pubkey: CHARLIE_PUBKEY, + pubkey: "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c", name: "charlie", agent_type: "codex", channels: ["general"], diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 5e31235a18..d4ca46916b 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -849,6 +849,69 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" await expect(autocomplete(page)).toHaveCount(0); }); +test("bot agents owned by another identity are mentionable when added to a shared channel", async ({ + page, +}) => { + const OTHER_OWNER_BOT_PUBKEY = + "7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b"; + await installMockBridge(page, { + relayAgents: [ + { + pubkey: OTHER_OWNER_BOT_PUBKEY, + name: "scout", + respondTo: "anyone", + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@scout"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("scout")).toBeVisible(); + await expect(dropdown.getByText("agent")).toBeVisible(); + + await input.press("Enter"); + await page.keyboard.type(" can you help?"); + await page.getByTestId("send-message").click(); + + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "scout" }); + await expect(mentionChip).toBeVisible(); +}); + +test("bot agents owned by another identity stay hidden when not added to a shared channel", async ({ + page, +}) => { + const OTHER_OWNER_BOT_PUBKEY = + "8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c"; + await installMockBridge(page, { + relayAgents: [ + { + pubkey: OTHER_OWNER_BOT_PUBKEY, + name: "ghost", + respondTo: "anyone", + // Deliberately not added to "general" — respondTo=anyone alone must + // not be enough; the fix requires real channel membership too. + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@ghost"); + + await expect(autocomplete(page)).toHaveCount(0); +}); + test("mentioning an in-channel stopped managed agent starts it before sending", async ({ page, }) => {