diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index db0573acd7c..c087031195f 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -145,11 +145,38 @@ async fn list_relay_agents_for_selection( if let Some(channel_id) = channel_id { membership_filter["#d"] = serde_json::json!([channel_id]); } - let membership_events = query_all_relay_pages(state, membership_filter) - .await - .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; + let mut dm_metadata_filter = serde_json::json!({ + "kinds": [39000], + "authors": [&relay_pubkey], + "#p": [&viewer_pubkey], + }); + if let Some(channel_id) = channel_id { + dm_metadata_filter["#d"] = serde_json::json!([channel_id]); + } + let (membership_events, dm_metadata_events) = tokio::try_join!( + async { + query_all_relay_pages(state, membership_filter) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}")) + }, + async { + query_all_relay_pages(state, dm_metadata_filter) + .await + .map_err(|error| format!("relay agent DM-participant query failed: {error}")) + }, + )?; let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey); + for (pubkey, channel_ids) in nostr_convert::dm_participant_channel_ids_from_events( + &dm_metadata_events, + &relay_pubkey, + &viewer_pubkey, + ) { + let existing = member_agent_channel_ids.entry(pubkey).or_default(); + existing.extend(channel_ids); + existing.sort(); + existing.dedup(); + } if let Some(requested_pubkeys) = requested_pubkeys { member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); } diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 64c8df05a79..748d14d8aa0 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -499,9 +499,9 @@ pub fn agents_from_events(events: &[Event]) -> Value { mod agent_directory; pub use agent_directory::{ - managed_agent_pubkeys_from_events, member_agent_channel_ids_from_events, - relay_agents_from_directory_events, relay_agents_from_managed_agent_events, - verified_agent_owners_from_profiles, + dm_participant_channel_ids_from_events, managed_agent_pubkeys_from_events, + member_agent_channel_ids_from_events, relay_agents_from_directory_events, + relay_agents_from_managed_agent_events, verified_agent_owners_from_profiles, }; // ── kind:13534 (relay membership list) ────────────────────────────────────── diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs index 28604de5e5f..1e0bf8167d3 100644 --- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -6,7 +6,9 @@ use nostr::Event; use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo}; -use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, tags_named}; +use super::{ + agents_from_events, first_tag_value, has_tag, profile_valid_oa_owner_pubkey, tags_named, +}; /// Collect valid agent pubkeys from kind:30177 `d` tags for follow-up relay /// queries. Malformed tags are ignored so one hostile event cannot invalidate @@ -189,3 +191,70 @@ pub fn member_agent_channel_ids_from_events( .map(|(pubkey, ids)| (pubkey, ids.into_iter().collect())) .collect() } + +/// Build a pubkey-to-channel-id candidate map from relay-signed DM metadata. +/// +/// Stream and forum agent membership carries an explicit `bot` role in +/// kind:39002. DM creation deliberately records every participant as an +/// ordinary member instead, while the current relay-signed kind:39000 head +/// carries the authoritative participant list in `p` tags. Only a current DM +/// head that includes the viewer can admit its other participants as agent +/// candidates; downstream signed runtime-directory and managed-policy checks +/// still decide which candidates are actually agents and whether they may +/// respond to that viewer. +pub fn dm_participant_channel_ids_from_events( + events: &[Event], + relay_pubkey: &str, + viewer_pubkey: &str, +) -> HashMap> { + let Ok(viewer_pubkey) = nostr::PublicKey::from_hex(viewer_pubkey) else { + return HashMap::new(); + }; + let viewer_pubkey = viewer_pubkey.to_hex(); + + // Fail closed on stale replacement heads: choose exactly one current + // relay-signed metadata event per channel before reading participants. + let mut latest_by_channel: HashMap = HashMap::new(); + for event in events { + if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) { + continue; + } + if first_tag_value(event, "t") != Some("dm") && !has_tag(event, "hidden") { + continue; + } + let Some(channel_id) = first_tag_value(event, "d") else { + continue; + }; + if latest_by_channel + .get(channel_id) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest_by_channel.insert(channel_id.to_string(), event); + } + } + + let mut channel_ids: HashMap> = HashMap::new(); + for (channel_id, event) in latest_by_channel { + let participants: Vec = tags_named(event, "p") + .filter_map(|tag| tag.get(1)) + .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) + .map(|pubkey| pubkey.to_hex()) + .collect(); + if !participants.contains(&viewer_pubkey) { + continue; + } + for pubkey in participants { + if pubkey != viewer_pubkey { + channel_ids + .entry(pubkey) + .or_default() + .insert(channel_id.clone()); + } + } + } + + channel_ids + .into_iter() + .map(|(pubkey, ids)| (pubkey, ids.into_iter().collect())) + .collect() +} diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 9401d19add4..c730cc69937 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -520,6 +520,89 @@ fn managed_agent_candidates_use_only_relay_signed_bot_membership() { assert!(!channel_ids.contains_key(&stranger)); } +#[test] +fn managed_agent_candidates_include_relay_signed_dm_participants() { + let relay_keys = Keys::generate(); + let viewer_pubkey = Keys::generate().public_key().to_hex(); + let agent_pubkey = Keys::generate().public_key().to_hex(); + let stranger = Keys::generate().public_key().to_hex(); + let dm = EventBuilder::new(Kind::Custom(39000), "") + .tags([ + Tag::parse(["d", "pm-dm"]).expect("parse d tag"), + Tag::parse(["t", "dm"]).expect("parse type tag"), + Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"), + Tag::parse(["p", &agent_pubkey]).expect("parse agent tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign DM metadata"); + let forged = ev( + 39000, + "", + vec![ + vec!["d", "forged-dm"], + vec!["t", "dm"], + vec!["p", &viewer_pubkey], + vec!["p", &stranger], + ], + ); + let stream = EventBuilder::new(Kind::Custom(39000), "") + .tags([ + Tag::parse(["d", "general"]).expect("parse d tag"), + Tag::parse(["t", "stream"]).expect("parse type tag"), + Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"), + Tag::parse(["p", &stranger]).expect("parse stranger tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign stream metadata"); + + let channel_ids = dm_participant_channel_ids_from_events( + &[forged, stream, dm], + &relay_keys.public_key().to_hex(), + &viewer_pubkey, + ); + + assert_eq!( + channel_ids.get(&agent_pubkey), + Some(&vec!["pm-dm".to_string()]) + ); + assert!(!channel_ids.contains_key(&viewer_pubkey)); + assert!(!channel_ids.contains_key(&stranger)); +} + +#[test] +fn managed_agent_dm_candidates_follow_only_the_latest_metadata_head() { + let relay_keys = Keys::generate(); + let viewer_pubkey = Keys::generate().public_key().to_hex(); + let removed_agent_pubkey = Keys::generate().public_key().to_hex(); + let old_head = EventBuilder::new(Kind::Custom(39000), "") + .tags([ + Tag::parse(["d", "pm-dm"]).expect("parse d tag"), + Tag::parse(["hidden"]).expect("parse hidden tag"), + Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"), + Tag::parse(["p", &removed_agent_pubkey]).expect("parse agent tag"), + ]) + .custom_created_at(nostr::Timestamp::from(10)) + .sign_with_keys(&relay_keys) + .expect("sign old DM metadata"); + let new_head = EventBuilder::new(Kind::Custom(39000), "") + .tags([ + Tag::parse(["d", "pm-dm"]).expect("parse d tag"), + Tag::parse(["t", "dm"]).expect("parse type tag"), + Tag::parse(["p", &viewer_pubkey]).expect("parse viewer tag"), + ]) + .custom_created_at(nostr::Timestamp::from(20)) + .sign_with_keys(&relay_keys) + .expect("sign new DM metadata"); + + let channel_ids = dm_participant_channel_ids_from_events( + &[old_head, new_head], + &relay_keys.public_key().to_hex(), + &viewer_pubkey, + ); + + assert!(channel_ids.is_empty()); +} + #[test] fn managed_agent_directory_query_pubkeys_reject_malformed_d_tags() { let valid_pubkey = Keys::generate().public_key().to_hex(); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 21880eca2ff..38f1498d9ab 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -224,7 +224,7 @@ test("getMentionableAgentPubkeys: scopes channel composers and fails closed with test("autocomplete helper extraction preserves safe filtering and labels", () => { assert.equal(isAgentMentionChannelType("stream"), true); assert.equal(isAgentMentionChannelType("forum"), true); - assert.equal(isAgentMentionChannelType("dm"), false); + assert.equal(isAgentMentionChannelType("dm"), true); assert.equal(isAgentMentionChannelType(null), false); assert.deepEqual( diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 4e1c787f92e..98f0d571536 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -216,7 +216,7 @@ export function filterAdmittedMentionPubkeys( } export function isAgentMentionChannelType(type?: string | null) { - return type === "stream" || type === "forum"; + return type === "stream" || type === "forum" || type === "dm"; } export function uniqueAutocompleteLabels( diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9069b052da4..e97b00f6e6c 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -832,7 +832,20 @@ export function useAddChannelMembersMutation(channelId: string | null) { // Invalidate the effective channel (the one actually mutated) not the // live hook-closure channel, which may have changed mid-send. const effectiveChannelId = variables?.channelId ?? channelId; - await invalidateChannelState(queryClient, effectiveChannelId); + await Promise.all([ + invalidateChannelState(queryClient, effectiveChannelId), + // Relay-agent discovery is membership-backed. Without this refresh, + // adding an existing agent through the members picker leaves the + // autocomplete directory stale and the same-named local persona can + // be offered as a new agent until the five-minute focus refresh. + ...(variables?.role === "bot" + ? [ + queryClient.invalidateQueries({ + queryKey: ["relay-agents"], + }), + ] + : []), + ]); }, }); } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index edcf523e36c..a2110f7ed0a 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1394,6 +1394,61 @@ test("relay-only shared agents stay hidden from DM mentions", async ({ await expect(autocomplete(page)).toHaveCount(0); }); +test("an existing remote agent in a DM is mentioned without creating a local runtime", async ({ + page, +}) => { + const remoteAgentPubkey = "7".repeat(64); + await installMockBridge(page, { + acpRuntimesCatalog: [], + personas: [ + { + id: "pm-persona", + displayName: "PM Bot", + systemPrompt: "Own product management.", + isActive: true, + }, + ], + relayAgents: [ + { + pubkey: remoteAgentPubkey, + ownerPubkey: MOCK_VIEWER_PUBKEY, + name: "PM Bot", + channelNames: ["alice-tyler"], + respondTo: "owner-only", + }, + ], + searchProfiles: [ + { + pubkey: remoteAgentPubkey, + displayName: "PM Bot", + ownerPubkey: MOCK_VIEWER_PUBKEY, + isAgent: true, + }, + ], + }); + await page.goto("/"); + await expect(page.getByTestId("channel-alice-tyler")).toBeVisible(); + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + + const input = page.getByTestId("message-input"); + await input.fill("@PM Bot"); + await autocomplete(page) + .getByTestId(`mention-suggestion-${remoteAgentPubkey}`) + .click(); + await input.press("End"); + await input.type("keep going"); + await input.press("Enter"); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@PM Bot keep going")) + .toContain(remoteAgentPubkey); + expect(await readCommandLog(page)).not.toContain("create_managed_agent"); + await expect( + page.getByText(/No agent runtime available/, { exact: false }), + ).toHaveCount(0); +}); + test("cached relay-agent suggestions are removed when channel authorization disappears", async ({ page, }) => {