From 687149e2a407dd5672cb51ec8554a488ef7103b8 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 01/18] fix(desktop): enrich relay agent directory for mention eligibility (#5363) Merge kind:30177 respond_to policy and kind:39002 channel membership into list_relay_agents so #4913 mention gates see production data (kind:10100 profiles are sparse). Add channel-member bot fallback in useMentions when directory channel_ids lag behind live membership. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 98 ++++++++++++++++++- .../lib/agentAutocompleteEligibility.test.mjs | 43 ++++++++ .../lib/agentAutocompleteEligibility.ts | 50 ++++++++-- .../src/features/messages/lib/useMentions.ts | 12 +++ 4 files changed, 191 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2d..6f21507d18 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,16 +1,100 @@ +use std::collections::{HashMap, HashSet}; + +use buzz_core_pkg::kind::KIND_MANAGED_AGENT; use tauri::State; use crate::{ app_state::AppState, managed_agents::{ - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, - RelayAgentInfo, DEFAULT_ACP_COMMAND, + agent_events::managed_agent_content_from_event, command_availability, + is_npm_global_install, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, + InstallRuntimeResult, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, }; +fn d_tag_from_event(event: &nostr::Event) -> Option { + event.tags.iter().find_map(|tag| { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) == Some("d") { + slice.get(1).filter(|value| !value.is_empty()).cloned() + } else { + None + } + }) +} + +async fn fetch_agent_channel_ids( + state: &AppState, + agent_pubkey: &str, +) -> Result, String> { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#p": [agent_pubkey], + })], + ) + .await?; + + let mut channel_ids = HashSet::new(); + for event in &events { + if let Some(channel_id) = d_tag_from_event(event) { + channel_ids.insert(channel_id); + } + } + let mut sorted: Vec = channel_ids.into_iter().collect(); + sorted.sort(); + Ok(sorted) +} + +async fn enrich_relay_agents_from_relay( + state: &AppState, + mut agents: Vec, +) -> Result, String> { + let definition_events = query_relay( + state, + &[serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + })], + ) + .await?; + + let mut definitions: HashMap)> = + HashMap::new(); + for event in &definition_events { + let Some(agent_pubkey) = d_tag_from_event(event) else { + continue; + }; + let Ok(content) = managed_agent_content_from_event(event) else { + continue; + }; + definitions.insert( + agent_pubkey, + (content.respond_to, content.respond_to_allowlist), + ); + } + + for agent in &mut agents { + if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { + agent.respond_to = Some(respond_to.clone()); + agent.respond_to_allowlist = allowlist.clone(); + } + + let discovered_channel_ids = fetch_agent_channel_ids(state, &agent.pubkey).await?; + if !discovered_channel_ids.is_empty() { + let mut merged: HashSet = agent.channel_ids.iter().cloned().collect(); + merged.extend(discovered_channel_ids); + let mut channel_ids: Vec = merged.into_iter().collect(); + channel_ids.sort(); + agent.channel_ids = channel_ids; + } + } + + Ok(agents) +} + mod post_install_verification; fn active_installs() -> &'static std::sync::Mutex> { @@ -1055,7 +1139,13 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result = + serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))?; + + // kind:10100 profiles are sparse: respond_to policy lives on kind:30177 and + // channel membership is on kind:39002. Merge both so Desktop mention + // eligibility (#4913 / #5363) sees the same data iOS already uses. + enrich_relay_agents_from_relay(state.inner(), agents).await } #[cfg(test)] diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 0f911ad41b..1a2dab8621 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -131,6 +131,49 @@ test("relayAgentCanRespondInChannel: requires exact channel membership and viewe ); }); +test("relayAgentCanRespondInChannel: accepts channel member bots when directory channel_ids lag", () => { + const agent = { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }; + const channelMemberAgentPubkeys = new Set([PUB_B]); + + assert.equal( + relayAgentCanRespondInChannel( + agent, + "general", + CURRENT_PUBKEY, + channelMemberAgentPubkeys, + ), + true, + ); +}); + +test("getMentionableAgentPubkeys: channel scope admits relay agents via live membership", () => { + const relayAgents = [ + { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }, + ]; + + assert.deepEqual( + getMentionableAgentPubkeys({ + currentPubkey: CURRENT_PUBKEY, + eligibilityScope: { type: "channel", channelId: "general" }, + managedAgentPubkeys: [PUB_A], + relayAgents, + sharedChannelIds: new Set(["general"]), + channelMemberAgentPubkeys: new Set([PUB_B]), + }), + new Set([PUB_A, PUB_B]), + ); +}); + test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => { const result = getMentionableAgentPubkeys({ eligibilityScope: { type: "community" }, diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 3fb4e23c15..afaddd23ab 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -10,9 +10,13 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { } export function relayAgentIsSharedWithUser( - agent: Pick, + agent: Pick< + RelayAgent, + "pubkey" | "channelIds" | "respondTo" | "respondToAllowlist" + >, sharedChannelIds: ReadonlySet, currentPubkey?: string | null, + channelMemberAgentPubkeys?: ReadonlySet, ) { const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) @@ -24,20 +28,43 @@ export function relayAgentIsSharedWithUser( .includes(normalizedCurrentPubkey); } + if (agent.respondTo !== "anyone") { + return false; + } + + if (agent.channelIds.some((channelId) => sharedChannelIds.has(channelId))) { + return true; + } + + // Relay directory channel_ids can lag behind live channel membership; callers + // pass bot members from the active channel composer as a fresher signal. return ( - agent.respondTo === "anyone" && - agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) + channelMemberAgentPubkeys?.has(normalizePubkey(agent.pubkey)) === true ); } export function relayAgentCanRespondInChannel( - agent: Pick, + agent: Pick< + RelayAgent, + "pubkey" | "channelIds" | "respondTo" | "respondToAllowlist" + >, channelId: string, currentPubkey?: string | null, + channelMemberAgentPubkeys?: ReadonlySet, ) { - return ( - agent.channelIds.includes(channelId) && - relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey) + const normalizedPubkey = normalizePubkey(agent.pubkey); + const inChannel = + agent.channelIds.includes(channelId) || + channelMemberAgentPubkeys?.has(normalizedPubkey) === true; + if (!inChannel) { + return false; + } + + return relayAgentIsSharedWithUser( + agent, + new Set([channelId]), + currentPubkey, + channelMemberAgentPubkeys, ); } @@ -47,12 +74,14 @@ export type AgentEligibilityScope = | { type: "managed-only" }; export function getMentionableAgentPubkeys({ + channelMemberAgentPubkeys, currentPubkey, eligibilityScope, managedAgentPubkeys, relayAgents, sharedChannelIds, }: { + channelMemberAgentPubkeys?: ReadonlySet; currentPubkey?: string | null; eligibilityScope: AgentEligibilityScope; managedAgentPubkeys: Iterable; @@ -68,11 +97,16 @@ export function getMentionableAgentPubkeys({ eligibilityScope.type === "managed-only" ? false : eligibilityScope.type === "community" - ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) + ? relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + ) : relayAgentCanRespondInChannel( agent, eligibilityScope.channelId, currentPubkey, + channelMemberAgentPubkeys, ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index cd52b1bebf..62dd12cbfa 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -197,9 +197,20 @@ export function useMentions( const mentionChannelId = isAgentMentionChannelType(options?.channelType) ? channelId : null; + const channelMemberAgentPubkeys = React.useMemo(() => { + if (!mentionChannelId) { + return undefined; + } + return new Set( + (members ?? []) + .filter((member) => member.isAgent === true || member.role === "bot") + .map((member) => normalizePubkey(member.pubkey)), + ); + }, [members, mentionChannelId]); const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ + channelMemberAgentPubkeys, currentPubkey, eligibilityScope: mentionChannelId ? { type: "channel", channelId: mentionChannelId } @@ -209,6 +220,7 @@ export function useMentions( sharedChannelIds, }), [ + channelMemberAgentPubkeys, currentPubkey, managedAgentPubkeys, mentionChannelId, From f57b1be1d9a7b5a8162c56bec6e7815deb8cfb57 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 02/18] test(desktop): assert owner-only agents stay hidden with member fallback Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 1a2dab8621..f93fdd2c20 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -131,6 +131,26 @@ test("relayAgentCanRespondInChannel: requires exact channel membership and viewe ); }); +test("relayAgentCanRespondInChannel: channel-member fallback still fail-closed for owner-only", () => { + const agent = { + pubkey: PUB_B, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: [], + }; + const channelMemberAgentPubkeys = new Set([PUB_B]); + + assert.equal( + relayAgentCanRespondInChannel( + agent, + "general", + OTHER_OWNER_PUBKEY, + channelMemberAgentPubkeys, + ), + false, + ); +}); + test("relayAgentCanRespondInChannel: accepts channel member bots when directory channel_ids lag", () => { const agent = { pubkey: PUB_B, From 230210ed5f2c67e94ce706d5ce8bc2aa1eec915a Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 03/18] fix(desktop): best-effort relay enrich + unit tests for #5363 Degrade gracefully when kind:30177 batch or per-agent kind:39002 membership queries fail instead of emptying the whole autocomplete. Extract pure helpers for testability and add unit tests for d-tag parsing, 30177 indexing, and channel_id merge/dedup. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 198 ++++++++++++++---- 1 file changed, 162 insertions(+), 36 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 6f21507d18..72a11f2f82 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -25,6 +25,53 @@ fn d_tag_from_event(event: &nostr::Event) -> Option { }) } +fn channel_ids_from_membership_events(events: &[nostr::Event]) -> Vec { + let mut channel_ids = HashSet::new(); + for event in events { + if let Some(channel_id) = d_tag_from_event(event) { + channel_ids.insert(channel_id); + } + } + let mut sorted: Vec = channel_ids.into_iter().collect(); + sorted.sort(); + sorted +} + +fn collect_managed_agent_definitions( + events: &[nostr::Event], +) -> HashMap)> { + let mut definitions = HashMap::new(); + for event in events { + let Some(agent_pubkey) = d_tag_from_event(event) else { + tracing::warn!("list_relay_agents: skipping kind:30177 event without d-tag"); + continue; + }; + let Ok(content) = managed_agent_content_from_event(event) else { + tracing::warn!( + agent_pubkey = %agent_pubkey, + "list_relay_agents: skipping unparsable kind:30177 content" + ); + continue; + }; + definitions.insert( + agent_pubkey, + (content.respond_to, content.respond_to_allowlist), + ); + } + definitions +} + +fn merge_channel_ids(existing: &[String], discovered: &[String]) -> Vec { + if discovered.is_empty() { + return existing.to_vec(); + } + let mut merged: HashSet = existing.iter().cloned().collect(); + merged.extend(discovered.iter().cloned()); + let mut channel_ids: Vec = merged.into_iter().collect(); + channel_ids.sort(); + channel_ids +} + async fn fetch_agent_channel_ids( state: &AppState, agent_pubkey: &str, @@ -38,43 +85,30 @@ async fn fetch_agent_channel_ids( ) .await?; - let mut channel_ids = HashSet::new(); - for event in &events { - if let Some(channel_id) = d_tag_from_event(event) { - channel_ids.insert(channel_id); - } - } - let mut sorted: Vec = channel_ids.into_iter().collect(); - sorted.sort(); - Ok(sorted) + Ok(channel_ids_from_membership_events(&events)) } async fn enrich_relay_agents_from_relay( state: &AppState, mut agents: Vec, -) -> Result, String> { - let definition_events = query_relay( +) -> Vec { + let definitions = match query_relay( state, &[serde_json::json!({ "kinds": [KIND_MANAGED_AGENT], })], ) - .await?; - - let mut definitions: HashMap)> = - HashMap::new(); - for event in &definition_events { - let Some(agent_pubkey) = d_tag_from_event(event) else { - continue; - }; - let Ok(content) = managed_agent_content_from_event(event) else { - continue; - }; - definitions.insert( - agent_pubkey, - (content.respond_to, content.respond_to_allowlist), - ); - } + .await + { + Ok(definition_events) => collect_managed_agent_definitions(&definition_events), + Err(error) => { + tracing::warn!( + error = %error, + "list_relay_agents: kind:30177 enrich failed; continuing with kind:10100 only" + ); + HashMap::new() + } + }; for agent in &mut agents { if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { @@ -82,17 +116,21 @@ async fn enrich_relay_agents_from_relay( agent.respond_to_allowlist = allowlist.clone(); } - let discovered_channel_ids = fetch_agent_channel_ids(state, &agent.pubkey).await?; - if !discovered_channel_ids.is_empty() { - let mut merged: HashSet = agent.channel_ids.iter().cloned().collect(); - merged.extend(discovered_channel_ids); - let mut channel_ids: Vec = merged.into_iter().collect(); - channel_ids.sort(); - agent.channel_ids = channel_ids; + match fetch_agent_channel_ids(state, &agent.pubkey).await { + Ok(discovered_channel_ids) => { + agent.channel_ids = merge_channel_ids(&agent.channel_ids, &discovered_channel_ids); + } + Err(error) => { + tracing::warn!( + agent_pubkey = %agent.pubkey, + error = %error, + "list_relay_agents: kind:39002 membership enrich failed for agent" + ); + } } } - Ok(agents) + agents } mod post_install_verification; @@ -1145,7 +1183,7 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result nostr::Event { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(39_002), "") + .tags(vec![Tag::parse(["d", channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap() + } + + fn test_managed_agent_definition_event(agent_pubkey: &str, respond_to: &str) -> nostr::Event { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let content = serde_json::json!({ + "name": "Scout", + "parallelism": 1, + "respond_to": respond_to, + }); + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content.to_string()) + .tags(vec![Tag::parse(["d", agent_pubkey]).unwrap()]) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn test_d_tag_from_event_reads_first_non_empty_d_tag() { + let event = test_membership_event("273e2bad-b694-4a0e-bc2b-aefcc7d027bb"); + assert_eq!( + d_tag_from_event(&event).as_deref(), + Some("273e2bad-b694-4a0e-bc2b-aefcc7d027bb") + ); + } + + #[test] + fn test_d_tag_from_event_returns_none_without_d_tag() { + use nostr::{EventBuilder, Keys, Kind}; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(39_002), "") + .sign_with_keys(&keys) + .unwrap(); + assert!(d_tag_from_event(&event).is_none()); + } + + #[test] + fn test_channel_ids_from_membership_events_deduplicates_and_sorts() { + let events = vec![ + test_membership_event("channel-b"), + test_membership_event("channel-a"), + test_membership_event("channel-b"), + ]; + assert_eq!( + channel_ids_from_membership_events(&events), + vec!["channel-a".to_string(), "channel-b".to_string()] + ); + } + + #[test] + fn test_collect_managed_agent_definitions_indexes_by_d_tag() { + let agent_pubkey = "a".repeat(64); + let events = vec![test_managed_agent_definition_event(&agent_pubkey, "anyone")]; + let definitions = collect_managed_agent_definitions(&events); + let (respond_to, allowlist) = definitions.get(&agent_pubkey).unwrap(); + assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); + assert!(allowlist.is_empty()); + } + + #[test] + fn test_merge_channel_ids_preserves_existing_and_adds_discovered() { + assert_eq!( + merge_channel_ids( + &["channel-z".to_string(), "channel-a".to_string()], + &["channel-b".to_string(), "channel-a".to_string()], + ), + vec![ + "channel-a".to_string(), + "channel-b".to_string(), + "channel-z".to_string(), + ] + ); + } + + #[test] + fn test_merge_channel_ids_returns_existing_when_discovered_is_empty() { + let existing = vec!["channel-a".to_string()]; + assert_eq!(merge_channel_ids(&existing, &[]), existing); + } + // ── adapter_needs_install (codex version gate) ──────────────────────────── /// plan_adapter_install is the pure install-plan seam used by From ebadfbc4facff1e1f712342aa3dbd1e298998cec Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 04/18] fix(desktop): drop unused mut in list_relay_agents Avoids clippy unused_mut warning that fails CI with -D warnings. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- desktop/src-tauri/src/commands/agent_discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 72a11f2f82..c51c6238e4 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1177,7 +1177,7 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result = + let agents: Vec = serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))?; // kind:10100 profiles are sparse: respond_to policy lives on kind:30177 and From 96e8d233ad438d748f4b7062f401210ca60c16fb Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 05/18] fix(desktop): address multi-model review findings for #5363 Rust: batch kind:30177/39002 queries with #d/#p filters and explicit limits; NIP-OA owner filter + created_at tie-break for 30177; batch membership grouping. TS: fix crashing test fixture (missing pubkey), add allowlist+lag-window test, document community-scope omission. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 270 +++++++++++++++--- .../lib/agentAutocompleteEligibility.test.mjs | 30 ++ .../lib/agentAutocompleteEligibility.ts | 6 +- 3 files changed, 261 insertions(+), 45 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index c51c6238e4..25937ca77c 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -37,15 +37,58 @@ fn channel_ids_from_membership_events(events: &[nostr::Event]) -> Vec { sorted } +fn channel_ids_by_agent_from_membership_events( + events: &[nostr::Event], + agent_pubkeys: &HashSet, +) -> HashMap> { + let mut by_agent: HashMap> = HashMap::new(); + for event in events { + let Some(channel_id) = d_tag_from_event(event) else { + continue; + }; + for tag in &event.tags { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) != Some("p") { + continue; + } + let Some(agent_pubkey) = slice.get(1).filter(|value| !value.is_empty()) else { + continue; + }; + if agent_pubkeys.contains(agent_pubkey) { + by_agent + .entry(agent_pubkey.clone()) + .or_default() + .insert(channel_id.clone()); + } + } + } + + let mut sorted: HashMap> = HashMap::with_capacity(by_agent.len()); + for (agent_pubkey, channel_ids) in by_agent { + let mut channel_ids: Vec = channel_ids.into_iter().collect(); + channel_ids.sort(); + sorted.insert(agent_pubkey, channel_ids); + } + sorted +} + fn collect_managed_agent_definitions( events: &[nostr::Event], + expected_owners: &HashMap, ) -> HashMap)> { - let mut definitions = HashMap::new(); + let mut definitions: HashMap, u64)> = + HashMap::new(); for event in events { let Some(agent_pubkey) = d_tag_from_event(event) else { tracing::warn!("list_relay_agents: skipping kind:30177 event without d-tag"); continue; }; + let event_author = event.pubkey.to_hex(); + if let Some(expected_owner) = expected_owners.get(&agent_pubkey) { + if event_author != *expected_owner { + continue; + } + } let Ok(content) = managed_agent_content_from_event(event) else { tracing::warn!( agent_pubkey = %agent_pubkey, @@ -53,12 +96,19 @@ fn collect_managed_agent_definitions( ); continue; }; - definitions.insert( - agent_pubkey, - (content.respond_to, content.respond_to_allowlist), - ); + let created_at = event.created_at.as_secs(); + match definitions.get(&agent_pubkey) { + Some((_, _, existing_created_at)) if created_at <= *existing_created_at => continue, + _ => definitions.insert( + agent_pubkey, + (content.respond_to, content.respond_to_allowlist, created_at), + ), + } } definitions + .into_iter() + .map(|(agent_pubkey, (respond_to, allowlist, _))| (agent_pubkey, (respond_to, allowlist))) + .collect() } fn merge_channel_ids(existing: &[String], discovered: &[String]) -> Vec { @@ -72,43 +122,111 @@ fn merge_channel_ids(existing: &[String], discovered: &[String]) -> Vec channel_ids } -async fn fetch_agent_channel_ids( +async fn fetch_agent_owner_pubkeys( state: &AppState, - agent_pubkey: &str, -) -> Result, String> { - let events = query_relay( + agent_pubkeys: &[String], +) -> HashMap { + if agent_pubkeys.is_empty() { + return HashMap::new(); + } + + match query_relay( state, &[serde_json::json!({ - "kinds": [39002], - "#p": [agent_pubkey], + "kinds": [0], + "authors": agent_pubkeys, + "limit": agent_pubkeys.len(), })], ) - .await?; + .await + { + Ok(profile_events) => profile_events + .into_iter() + .filter_map(|event| { + nostr_convert::profile_valid_oa_owner_pubkey(&event) + .map(|owner| (event.pubkey.to_hex(), owner)) + }) + .collect(), + Err(error) => { + tracing::warn!( + error = %error, + "list_relay_agents: kind:0 owner lookup failed; continuing without owner filter" + ); + HashMap::new() + } + } +} + +async fn fetch_managed_agent_definitions( + state: &AppState, + agent_pubkeys: &[String], + expected_owners: &HashMap, +) -> HashMap)> { + if agent_pubkeys.is_empty() { + return HashMap::new(); + } - Ok(channel_ids_from_membership_events(&events)) + let filter = serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + "#d": agent_pubkeys, + "limit": agent_pubkeys.len(), + }); + + match query_relay(state, &[filter]).await { + Ok(definition_events) => { + collect_managed_agent_definitions(&definition_events, expected_owners) + } + Err(error) => { + tracing::warn!( + error = %error, + "list_relay_agents: kind:30177 enrich failed; continuing with kind:10100 only" + ); + HashMap::new() + } + } } -async fn enrich_relay_agents_from_relay( +async fn fetch_channel_ids_by_agent( state: &AppState, - mut agents: Vec, -) -> Vec { - let definitions = match query_relay( + agent_pubkeys: &[String], +) -> HashMap> { + if agent_pubkeys.is_empty() { + return HashMap::new(); + } + + match query_relay( state, &[serde_json::json!({ - "kinds": [KIND_MANAGED_AGENT], + "kinds": [39002], + "#p": agent_pubkeys, + "limit": agent_pubkeys.len(), })], ) .await { - Ok(definition_events) => collect_managed_agent_definitions(&definition_events), + Ok(membership_events) => channel_ids_by_agent_from_membership_events( + &membership_events, + &agent_pubkeys.iter().cloned().collect(), + ), Err(error) => { tracing::warn!( error = %error, - "list_relay_agents: kind:30177 enrich failed; continuing with kind:10100 only" + "list_relay_agents: kind:39002 membership enrich failed" ); HashMap::new() } - }; + } +} + +async fn enrich_relay_agents_from_relay( + state: &AppState, + mut agents: Vec, +) -> Vec { + let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); + let expected_owners = fetch_agent_owner_pubkeys(state, &agent_pubkeys).await; + let definitions = + fetch_managed_agent_definitions(state, &agent_pubkeys, &expected_owners).await; + let channel_ids_by_agent = fetch_channel_ids_by_agent(state, &agent_pubkeys).await; for agent in &mut agents { if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { @@ -116,17 +234,8 @@ async fn enrich_relay_agents_from_relay( agent.respond_to_allowlist = allowlist.clone(); } - match fetch_agent_channel_ids(state, &agent.pubkey).await { - Ok(discovered_channel_ids) => { - agent.channel_ids = merge_channel_ids(&agent.channel_ids, &discovered_channel_ids); - } - Err(error) => { - tracing::warn!( - agent_pubkey = %agent.pubkey, - error = %error, - "list_relay_agents: kind:39002 membership enrich failed for agent" - ); - } + if let Some(discovered_channel_ids) = channel_ids_by_agent.get(&agent.pubkey) { + agent.channel_ids = merge_channel_ids(&agent.channel_ids, discovered_channel_ids); } } @@ -1262,32 +1371,38 @@ mod tests { // ── relay agent directory enrich helpers ────────────────────────────────── - fn test_membership_event(channel_id: &str) -> nostr::Event { + fn test_membership_event(channel_id: &str, member_pubkey: &str) -> nostr::Event { use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); EventBuilder::new(Kind::Custom(39_002), "") - .tags(vec![Tag::parse(["d", channel_id]).unwrap()]) + .tags(vec![ + Tag::parse(["d", channel_id]).unwrap(), + Tag::parse(["p", member_pubkey, "", "member"]).unwrap(), + ]) .sign_with_keys(&keys) .unwrap() } - fn test_managed_agent_definition_event(agent_pubkey: &str, respond_to: &str) -> nostr::Event { - use nostr::{EventBuilder, Keys, Kind, Tag}; + fn test_managed_agent_definition_event( + agent_pubkey: &str, + respond_to: &str, + author_keys: &nostr::Keys, + ) -> nostr::Event { + use nostr::{EventBuilder, Kind, Tag}; let content = serde_json::json!({ "name": "Scout", "parallelism": 1, "respond_to": respond_to, }); - let keys = Keys::generate(); EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content.to_string()) .tags(vec![Tag::parse(["d", agent_pubkey]).unwrap()]) - .sign_with_keys(&keys) + .sign_with_keys(author_keys) .unwrap() } #[test] fn test_d_tag_from_event_reads_first_non_empty_d_tag() { - let event = test_membership_event("273e2bad-b694-4a0e-bc2b-aefcc7d027bb"); + let event = test_membership_event("273e2bad-b694-4a0e-bc2b-aefcc7d027bb", &"a".repeat(64)); assert_eq!( d_tag_from_event(&event).as_deref(), Some("273e2bad-b694-4a0e-bc2b-aefcc7d027bb") @@ -1306,10 +1421,11 @@ mod tests { #[test] fn test_channel_ids_from_membership_events_deduplicates_and_sorts() { + let member_pubkey = "a".repeat(64); let events = vec![ - test_membership_event("channel-b"), - test_membership_event("channel-a"), - test_membership_event("channel-b"), + test_membership_event("channel-b", &member_pubkey), + test_membership_event("channel-a", &member_pubkey), + test_membership_event("channel-b", &member_pubkey), ]; assert_eq!( channel_ids_from_membership_events(&events), @@ -1317,16 +1433,82 @@ mod tests { ); } + #[test] + fn test_channel_ids_by_agent_from_membership_events_groups_by_p_tag() { + let agent_a = "a".repeat(64); + let agent_b = "b".repeat(64); + let events = vec![ + test_membership_event("channel-a", &agent_a), + test_membership_event("channel-b", &agent_b), + test_membership_event("channel-c", &agent_a), + ]; + let agent_pubkeys = HashSet::from([agent_a.clone(), agent_b.clone()]); + let by_agent = channel_ids_by_agent_from_membership_events(&events, &agent_pubkeys); + assert_eq!( + by_agent.get(&agent_a), + Some(&vec!["channel-a".to_string(), "channel-c".to_string()]) + ); + assert_eq!(by_agent.get(&agent_b), Some(&vec!["channel-b".to_string()])); + } + #[test] fn test_collect_managed_agent_definitions_indexes_by_d_tag() { + use nostr::Keys; let agent_pubkey = "a".repeat(64); - let events = vec![test_managed_agent_definition_event(&agent_pubkey, "anyone")]; - let definitions = collect_managed_agent_definitions(&events); + let owner_keys = Keys::generate(); + let events = vec![test_managed_agent_definition_event( + &agent_pubkey, + "anyone", + &owner_keys, + )]; + let definitions = collect_managed_agent_definitions(&events, &HashMap::new()); let (respond_to, allowlist) = definitions.get(&agent_pubkey).unwrap(); assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); assert!(allowlist.is_empty()); } + #[test] + fn test_collect_managed_agent_definitions_prefers_newest_created_at() { + use nostr::{EventBuilder, Kind, Tag, Timestamp}; + let agent_pubkey = "a".repeat(64); + let owner_keys = nostr::Keys::generate(); + let older = EventBuilder::new( + Kind::Custom(KIND_MANAGED_AGENT as u16), + r#"{"name":"Scout","parallelism":1,"respond_to":"owner-only"}"#, + ) + .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) + .created_at(Timestamp::from(100)) + .sign_with_keys(&owner_keys) + .unwrap(); + let newer = EventBuilder::new( + Kind::Custom(KIND_MANAGED_AGENT as u16), + r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, + ) + .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) + .created_at(Timestamp::from(200)) + .sign_with_keys(&owner_keys) + .unwrap(); + let definitions = collect_managed_agent_definitions(&[older, newer], &HashMap::new()); + let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); + assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); + } + + #[test] + fn test_collect_managed_agent_definitions_filters_unexpected_authors() { + use nostr::Keys; + let agent_pubkey = "a".repeat(64); + let owner_keys = Keys::generate(); + let spoof_keys = Keys::generate(); + let expected_owners = + HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); + let legitimate = test_managed_agent_definition_event(&agent_pubkey, "anyone", &owner_keys); + let spoofed = test_managed_agent_definition_event(&agent_pubkey, "owner-only", &spoof_keys); + let definitions = + collect_managed_agent_definitions(&[legitimate, spoofed], &expected_owners); + let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); + assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); + } + #[test] fn test_merge_channel_ids_preserves_existing_and_adds_discovered() { assert_eq!( diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index f93fdd2c20..3beda615ba 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -112,6 +112,7 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" test("relayAgentCanRespondInChannel: requires exact channel membership and viewer access", () => { const agent = { + pubkey: PUB_B, respondTo: "allowlist", respondToAllowlist: [CURRENT_PUBKEY], channelIds: ["general"], @@ -151,6 +152,35 @@ test("relayAgentCanRespondInChannel: channel-member fallback still fail-closed f ); }); +test("relayAgentCanRespondInChannel: allowlist agents use live membership during 39002 lag", () => { + const agent = { + pubkey: PUB_B, + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: [], + }; + const channelMemberAgentPubkeys = new Set([PUB_B]); + + assert.equal( + relayAgentCanRespondInChannel( + agent, + "general", + CURRENT_PUBKEY, + channelMemberAgentPubkeys, + ), + true, + ); + assert.equal( + relayAgentCanRespondInChannel( + agent, + "general", + OTHER_OWNER_PUBKEY, + channelMemberAgentPubkeys, + ), + false, + ); +}); + test("relayAgentCanRespondInChannel: accepts channel member bots when directory channel_ids lag", () => { const agent = { pubkey: PUB_B, diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index afaddd23ab..d70c23c475 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -22,6 +22,8 @@ export function relayAgentIsSharedWithUser( ? normalizePubkey(currentPubkey) : null; + // Allowlist agents skip the shared-channel overlap check; once the viewer is + // allowlisted, live NIP-29 membership can admit them during 39002 lag windows. if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { return agent.respondToAllowlist .map((pubkey) => normalizePubkey(pubkey)) @@ -97,7 +99,9 @@ export function getMentionableAgentPubkeys({ eligibilityScope.type === "managed-only" ? false : eligibilityScope.type === "community" - ? relayAgentIsSharedWithUser( + ? // Community/global autocomplete has no single channel context, so we + // intentionally omit the live-membership fallback here. + relayAgentIsSharedWithUser( agent, sharedChannelIds, currentPubkey, From 4dde7cbf78587dc99e2bc1509a6b275e4083d053 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 06/18] fix(desktop): size relay batch query limits by event cardinality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39002 returns one event per channel, not per agent — size limit from union of kind:10100 channel hints with headroom. 30177 allows multiple events per d-tag (multi-author) with 4× agent headroom above relay default. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 83 +++++++++++++++++-- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 25937ca77c..9a12f94381 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -14,6 +14,29 @@ use crate::{ relay::query_relay, }; +/// Relay HTTP `/query` default page size when `limit` is omitted. +const RELAY_DEFAULT_QUERY_LIMIT: usize = 100; + +/// kind:39002 returns one members-list event per channel, not per agent. +fn channel_membership_query_limit(agents: &[RelayAgentInfo]) -> usize { + let agent_count = agents.len(); + let mut channel_ids = HashSet::new(); + for agent in agents { + channel_ids.extend(agent.channel_ids.iter().cloned()); + } + // One 39002 event per channel where any agent is a member; sparse 10100 + // channel_ids under-count, so keep headroom above the hint. + let channel_cardinality = channel_ids.len().max(agent_count); + channel_cardinality + .saturating_mul(2) + .max(RELAY_DEFAULT_QUERY_LIMIT) +} + +/// kind:30177 is replaceable by (author, kind, d): several events can share a d-tag. +fn managed_agent_definition_query_limit(agent_count: usize) -> usize { + agent_count.saturating_mul(4).max(RELAY_DEFAULT_QUERY_LIMIT) +} + fn d_tag_from_event(event: &nostr::Event) -> Option { event.tags.iter().find_map(|tag| { let slice = tag.as_slice(); @@ -169,7 +192,7 @@ async fn fetch_managed_agent_definitions( let filter = serde_json::json!({ "kinds": [KIND_MANAGED_AGENT], "#d": agent_pubkeys, - "limit": agent_pubkeys.len(), + "limit": managed_agent_definition_query_limit(agent_pubkeys.len()), }); match query_relay(state, &[filter]).await { @@ -188,26 +211,28 @@ async fn fetch_managed_agent_definitions( async fn fetch_channel_ids_by_agent( state: &AppState, - agent_pubkeys: &[String], + agents: &[RelayAgentInfo], ) -> HashMap> { - if agent_pubkeys.is_empty() { + if agents.is_empty() { return HashMap::new(); } + let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); + let agent_pubkey_set: HashSet = agent_pubkeys.iter().cloned().collect(); + match query_relay( state, &[serde_json::json!({ "kinds": [39002], "#p": agent_pubkeys, - "limit": agent_pubkeys.len(), + "limit": channel_membership_query_limit(agents), })], ) .await { - Ok(membership_events) => channel_ids_by_agent_from_membership_events( - &membership_events, - &agent_pubkeys.iter().cloned().collect(), - ), + Ok(membership_events) => { + channel_ids_by_agent_from_membership_events(&membership_events, &agent_pubkey_set) + } Err(error) => { tracing::warn!( error = %error, @@ -226,7 +251,7 @@ async fn enrich_relay_agents_from_relay( let expected_owners = fetch_agent_owner_pubkeys(state, &agent_pubkeys).await; let definitions = fetch_managed_agent_definitions(state, &agent_pubkeys, &expected_owners).await; - let channel_ids_by_agent = fetch_channel_ids_by_agent(state, &agent_pubkeys).await; + let channel_ids_by_agent = fetch_channel_ids_by_agent(state, &agents).await; for agent in &mut agents { if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { @@ -1530,6 +1555,46 @@ mod tests { assert_eq!(merge_channel_ids(&existing, &[]), existing); } + fn test_relay_agent(channel_ids: &[&str]) -> RelayAgentInfo { + RelayAgentInfo { + pubkey: "a".repeat(64), + name: "Scout".to_string(), + agent_type: "agent".to_string(), + channels: vec![], + channel_ids: channel_ids.iter().map(|id| (*id).to_string()).collect(), + capabilities: vec![], + status: "offline".to_string(), + respond_to: None, + respond_to_allowlist: vec![], + } + } + + #[test] + fn test_channel_membership_query_limit_scales_with_channel_cardinality() { + let agents = vec![ + test_relay_agent(&["channel-a", "channel-b", "channel-c"]), + test_relay_agent(&["channel-d"]), + ]; + assert_eq!(channel_membership_query_limit(&agents), 8); + } + + #[test] + fn test_channel_membership_query_limit_floors_at_relay_default() { + assert_eq!( + channel_membership_query_limit(&[test_relay_agent(&[])]), + RELAY_DEFAULT_QUERY_LIMIT + ); + } + + #[test] + fn test_managed_agent_definition_query_limit_allows_multiple_authors_per_d_tag() { + assert_eq!(managed_agent_definition_query_limit(5), 20); + assert_eq!( + managed_agent_definition_query_limit(1), + RELAY_DEFAULT_QUERY_LIMIT + ); + } + // ── adapter_needs_install (codex version gate) ──────────────────────────── /// plan_adapter_install is the pure install-plan seam used by From 10fcdb472530a8a833b36bdd9fbbc0c315d2492c Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 07/18] fix(desktop): close R2 review gaps for relay agent mention enrich Fail-closed kind:30177 when NIP-OA owner is unverified, fix query-limit test assertions against the relay floor, remove dead membership helper, parallelize kind:0/39002 fetches, and add community-scope plus channel-switch hardening tests. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 94 +++++++++++-------- .../lib/agentAutocompleteEligibility.test.mjs | 23 +++++ .../src/features/messages/lib/useMentions.ts | 5 +- 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9a12f94381..7a75a69f83 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -48,18 +48,6 @@ fn d_tag_from_event(event: &nostr::Event) -> Option { }) } -fn channel_ids_from_membership_events(events: &[nostr::Event]) -> Vec { - let mut channel_ids = HashSet::new(); - for event in events { - if let Some(channel_id) = d_tag_from_event(event) { - channel_ids.insert(channel_id); - } - } - let mut sorted: Vec = channel_ids.into_iter().collect(); - sorted.sort(); - sorted -} - fn channel_ids_by_agent_from_membership_events( events: &[nostr::Event], agent_pubkeys: &HashSet, @@ -107,10 +95,11 @@ fn collect_managed_agent_definitions( continue; }; let event_author = event.pubkey.to_hex(); - if let Some(expected_owner) = expected_owners.get(&agent_pubkey) { - if event_author != *expected_owner { - continue; - } + let Some(expected_owner) = expected_owners.get(&agent_pubkey) else { + continue; + }; + if event_author != *expected_owner { + continue; } let Ok(content) = managed_agent_content_from_event(event) else { tracing::warn!( @@ -248,15 +237,19 @@ async fn enrich_relay_agents_from_relay( mut agents: Vec, ) -> Vec { let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); - let expected_owners = fetch_agent_owner_pubkeys(state, &agent_pubkeys).await; + let (expected_owners, channel_ids_by_agent) = tokio::join!( + fetch_agent_owner_pubkeys(state, &agent_pubkeys), + fetch_channel_ids_by_agent(state, &agents), + ); let definitions = fetch_managed_agent_definitions(state, &agent_pubkeys, &expected_owners).await; - let channel_ids_by_agent = fetch_channel_ids_by_agent(state, &agents).await; for agent in &mut agents { - if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { - agent.respond_to = Some(respond_to.clone()); - agent.respond_to_allowlist = allowlist.clone(); + if agent.respond_to.is_none() { + if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { + agent.respond_to = Some(respond_to.clone()); + agent.respond_to_allowlist = allowlist.clone(); + } } if let Some(discovered_channel_ids) = channel_ids_by_agent.get(&agent.pubkey) { @@ -1444,20 +1437,6 @@ mod tests { assert!(d_tag_from_event(&event).is_none()); } - #[test] - fn test_channel_ids_from_membership_events_deduplicates_and_sorts() { - let member_pubkey = "a".repeat(64); - let events = vec![ - test_membership_event("channel-b", &member_pubkey), - test_membership_event("channel-a", &member_pubkey), - test_membership_event("channel-b", &member_pubkey), - ]; - assert_eq!( - channel_ids_from_membership_events(&events), - vec!["channel-a".to_string(), "channel-b".to_string()] - ); - } - #[test] fn test_channel_ids_by_agent_from_membership_events_groups_by_p_tag() { let agent_a = "a".repeat(64); @@ -1486,7 +1465,9 @@ mod tests { "anyone", &owner_keys, )]; - let definitions = collect_managed_agent_definitions(&events, &HashMap::new()); + let expected_owners = + HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); + let definitions = collect_managed_agent_definitions(&events, &expected_owners); let (respond_to, allowlist) = definitions.get(&agent_pubkey).unwrap(); assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); assert!(allowlist.is_empty()); @@ -1513,7 +1494,9 @@ mod tests { .created_at(Timestamp::from(200)) .sign_with_keys(&owner_keys) .unwrap(); - let definitions = collect_managed_agent_definitions(&[older, newer], &HashMap::new()); + let expected_owners = + HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); + let definitions = collect_managed_agent_definitions(&[older, newer], &expected_owners); let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); } @@ -1534,6 +1517,20 @@ mod tests { assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); } + #[test] + fn test_collect_managed_agent_definitions_skips_when_owner_unverified() { + use nostr::Keys; + let agent_pubkey = "a".repeat(64); + let owner_keys = Keys::generate(); + let events = vec![test_managed_agent_definition_event( + &agent_pubkey, + "anyone", + &owner_keys, + )]; + let definitions = collect_managed_agent_definitions(&events, &HashMap::new()); + assert!(definitions.is_empty()); + } + #[test] fn test_merge_channel_ids_preserves_existing_and_adds_discovered() { assert_eq!( @@ -1575,7 +1572,18 @@ mod tests { test_relay_agent(&["channel-a", "channel-b", "channel-c"]), test_relay_agent(&["channel-d"]), ]; - assert_eq!(channel_membership_query_limit(&agents), 8); + assert_eq!( + channel_membership_query_limit(&agents), + RELAY_DEFAULT_QUERY_LIMIT + ); + } + + #[test] + fn test_channel_membership_query_limit_scales_above_relay_default() { + let channel_ids: Vec = (0..51).map(|i| format!("channel-{i}")).collect(); + let channel_refs: Vec<&str> = channel_ids.iter().map(String::as_str).collect(); + let agents = vec![test_relay_agent(&channel_refs)]; + assert_eq!(channel_membership_query_limit(&agents), 102); } #[test] @@ -1588,13 +1596,21 @@ mod tests { #[test] fn test_managed_agent_definition_query_limit_allows_multiple_authors_per_d_tag() { - assert_eq!(managed_agent_definition_query_limit(5), 20); + assert_eq!( + managed_agent_definition_query_limit(5), + RELAY_DEFAULT_QUERY_LIMIT + ); assert_eq!( managed_agent_definition_query_limit(1), RELAY_DEFAULT_QUERY_LIMIT ); } + #[test] + fn test_managed_agent_definition_query_limit_scales_above_relay_default() { + assert_eq!(managed_agent_definition_query_limit(26), 104); + } + // ── adapter_needs_install (codex version gate) ──────────────────────────── /// plan_adapter_install is the pure install-plan seam used by diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 3beda615ba..b43f986a1f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -255,6 +255,29 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); +test("getMentionableAgentPubkeys: community scope ignores live membership fallback", () => { + const relayAgents = [ + { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }, + ]; + + assert.deepEqual( + getMentionableAgentPubkeys({ + eligibilityScope: { type: "community" }, + managedAgentPubkeys: [PUB_A], + currentPubkey: CURRENT_PUBKEY, + relayAgents, + sharedChannelIds: new Set(["general"]), + channelMemberAgentPubkeys: new Set([PUB_B]), + }), + new Set([PUB_A]), + ); +}); + test("getMentionableAgentPubkeys: scopes channel composers and fails closed without context", () => { const relayAgents = [ { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 62dd12cbfa..a6f1de262e 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -201,12 +201,15 @@ export function useMentions( if (!mentionChannelId) { return undefined; } + if (externalMembers === undefined && membersQuery.isLoading) { + return new Set(); + } return new Set( (members ?? []) .filter((member) => member.isAgent === true || member.role === "bot") .map((member) => normalizePubkey(member.pubkey)), ); - }, [members, mentionChannelId]); + }, [externalMembers, members, mentionChannelId, membersQuery.isLoading]); const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ From 5c20c96290441f638d967334975fd1e0d21774e9 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 08/18] fix(desktop): scope kind:30177 query by verified owners (R3 #1/#2) Add server-side authors filter from NIP-OA owner map and skip the 30177 fetch when no owners are verified. Fix misleading kind:0 error log to reflect fail-closed enrichment behavior. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- desktop/src-tauri/src/commands/agent_discovery.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 7a75a69f83..4dd20227ac 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -162,7 +162,7 @@ async fn fetch_agent_owner_pubkeys( Err(error) => { tracing::warn!( error = %error, - "list_relay_agents: kind:0 owner lookup failed; continuing without owner filter" + "list_relay_agents: kind:0 owner lookup failed; skipping kind:30177 enrichment" ); HashMap::new() } @@ -174,13 +174,21 @@ async fn fetch_managed_agent_definitions( agent_pubkeys: &[String], expected_owners: &HashMap, ) -> HashMap)> { - if agent_pubkeys.is_empty() { + if agent_pubkeys.is_empty() || expected_owners.is_empty() { return HashMap::new(); } + let owner_pubkeys: Vec = expected_owners + .values() + .cloned() + .collect::>() + .into_iter() + .collect(); + let filter = serde_json::json!({ "kinds": [KIND_MANAGED_AGENT], "#d": agent_pubkeys, + "authors": owner_pubkeys, "limit": managed_agent_definition_query_limit(agent_pubkeys.len()), }); From 654a438e80e7c56de1e19defd0d9b4f9ac9877e2 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:27 -0500 Subject: [PATCH 09/18] fix(desktop): apply R4 hardening for relay agent enrich (#5363) Owner-verified kind:30177 now overrides 10100 respond_to; sparse 39002 queries request up to relay max page limit; successful membership query replaces channel_ids instead of unioning with stale 10100 hints. Co-authored-by: Cursor Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 4dd20227ac..93267b9d23 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -16,25 +16,36 @@ use crate::{ /// Relay HTTP `/query` default page size when `limit` is omitted. const RELAY_DEFAULT_QUERY_LIMIT: usize = 100; +/// Relay hard cap for explicit `limit` (`DEFAULT_MAX_PAGE_LIMIT` in buzz-db). +const RELAY_MAX_QUERY_LIMIT: usize = 1000; /// kind:39002 returns one members-list event per channel, not per agent. fn channel_membership_query_limit(agents: &[RelayAgentInfo]) -> usize { let agent_count = agents.len(); + let sparse_10100 = agents.iter().all(|agent| agent.channel_ids.is_empty()); + if sparse_10100 { + // kind:10100 often omits channel_ids; cardinality is unknown — request max page. + return RELAY_MAX_QUERY_LIMIT; + } + let mut channel_ids = HashSet::new(); for agent in agents { channel_ids.extend(agent.channel_ids.iter().cloned()); } - // One 39002 event per channel where any agent is a member; sparse 10100 - // channel_ids under-count, so keep headroom above the hint. + // One 39002 event per channel where any agent is a member. let channel_cardinality = channel_ids.len().max(agent_count); channel_cardinality .saturating_mul(2) .max(RELAY_DEFAULT_QUERY_LIMIT) + .min(RELAY_MAX_QUERY_LIMIT) } /// kind:30177 is replaceable by (author, kind, d): several events can share a d-tag. fn managed_agent_definition_query_limit(agent_count: usize) -> usize { - agent_count.saturating_mul(4).max(RELAY_DEFAULT_QUERY_LIMIT) + agent_count + .saturating_mul(4) + .max(RELAY_DEFAULT_QUERY_LIMIT) + .min(RELAY_MAX_QUERY_LIMIT) } fn d_tag_from_event(event: &nostr::Event) -> Option { @@ -209,9 +220,9 @@ async fn fetch_managed_agent_definitions( async fn fetch_channel_ids_by_agent( state: &AppState, agents: &[RelayAgentInfo], -) -> HashMap> { +) -> Option>> { if agents.is_empty() { - return HashMap::new(); + return Some(HashMap::new()); } let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); @@ -227,15 +238,16 @@ async fn fetch_channel_ids_by_agent( ) .await { - Ok(membership_events) => { - channel_ids_by_agent_from_membership_events(&membership_events, &agent_pubkey_set) - } + Ok(membership_events) => Some(channel_ids_by_agent_from_membership_events( + &membership_events, + &agent_pubkey_set, + )), Err(error) => { tracing::warn!( error = %error, "list_relay_agents: kind:39002 membership enrich failed" ); - HashMap::new() + None } } } @@ -253,15 +265,18 @@ async fn enrich_relay_agents_from_relay( fetch_managed_agent_definitions(state, &agent_pubkeys, &expected_owners).await; for agent in &mut agents { - if agent.respond_to.is_none() { - if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { - agent.respond_to = Some(respond_to.clone()); - agent.respond_to_allowlist = allowlist.clone(); - } + // Owner-verified kind:30177 overrides kind:10100 self-declared policy when present. + if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { + agent.respond_to = Some(respond_to.clone()); + agent.respond_to_allowlist = allowlist.clone(); } - if let Some(discovered_channel_ids) = channel_ids_by_agent.get(&agent.pubkey) { - agent.channel_ids = merge_channel_ids(&agent.channel_ids, discovered_channel_ids); + if let Some(discovered_by_agent) = &channel_ids_by_agent { + // kind:39002 is authoritative for membership; keep 10100 hints only on query failure. + agent.channel_ids = discovered_by_agent + .get(&agent.pubkey) + .cloned() + .unwrap_or_default(); } } @@ -1595,10 +1610,10 @@ mod tests { } #[test] - fn test_channel_membership_query_limit_floors_at_relay_default() { + fn test_channel_membership_query_limit_uses_max_page_when_10100_sparse() { assert_eq!( channel_membership_query_limit(&[test_relay_agent(&[])]), - RELAY_DEFAULT_QUERY_LIMIT + RELAY_MAX_QUERY_LIMIT ); } From 92c6fb54ee8a7e200ab1be96c010de86cc7bbb5c Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 10/18] fix(desktop): apply R5 gate fixes for relay agent enrich (#5363) Remove dead merge_channel_ids helper, preserve 10100 channel hints when 39002 page omits an agent, tie-break kind:30177 by event id on equal created_at, and document community-scope SECURITY trust boundary. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 57 +++++++------------ .../lib/agentAutocompleteEligibility.ts | 5 +- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 93267b9d23..cde9b63dc2 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -98,7 +98,7 @@ fn collect_managed_agent_definitions( events: &[nostr::Event], expected_owners: &HashMap, ) -> HashMap)> { - let mut definitions: HashMap, u64)> = + let mut definitions: HashMap, u64, String)> = HashMap::new(); for event in events { let Some(agent_pubkey) = d_tag_from_event(event) else { @@ -120,31 +120,31 @@ fn collect_managed_agent_definitions( continue; }; let created_at = event.created_at.as_secs(); + let event_id = event.id.to_hex(); match definitions.get(&agent_pubkey) { - Some((_, _, existing_created_at)) if created_at <= *existing_created_at => continue, + Some((_, _, existing_created_at, existing_event_id)) + if created_at < *existing_created_at + || (created_at == *existing_created_at && event_id <= *existing_event_id) => + { + continue + } _ => definitions.insert( agent_pubkey, - (content.respond_to, content.respond_to_allowlist, created_at), + ( + content.respond_to, + content.respond_to_allowlist, + created_at, + event_id, + ), ), } } definitions .into_iter() - .map(|(agent_pubkey, (respond_to, allowlist, _))| (agent_pubkey, (respond_to, allowlist))) + .map(|(agent_pubkey, (respond_to, allowlist, _, _))| (agent_pubkey, (respond_to, allowlist))) .collect() } -fn merge_channel_ids(existing: &[String], discovered: &[String]) -> Vec { - if discovered.is_empty() { - return existing.to_vec(); - } - let mut merged: HashSet = existing.iter().cloned().collect(); - merged.extend(discovered.iter().cloned()); - let mut channel_ids: Vec = merged.into_iter().collect(); - channel_ids.sort(); - channel_ids -} - async fn fetch_agent_owner_pubkeys( state: &AppState, agent_pubkeys: &[String], @@ -272,11 +272,13 @@ async fn enrich_relay_agents_from_relay( } if let Some(discovered_by_agent) = &channel_ids_by_agent { - // kind:39002 is authoritative for membership; keep 10100 hints only on query failure. + // kind:39002 is authoritative for membership; keep 10100 hints on query failure + // or when an agent is absent from a truncated page (R5 M2). + let existing_channel_ids = agent.channel_ids.clone(); agent.channel_ids = discovered_by_agent .get(&agent.pubkey) .cloned() - .unwrap_or_default(); + .unwrap_or(existing_channel_ids); } } @@ -1554,27 +1556,6 @@ mod tests { assert!(definitions.is_empty()); } - #[test] - fn test_merge_channel_ids_preserves_existing_and_adds_discovered() { - assert_eq!( - merge_channel_ids( - &["channel-z".to_string(), "channel-a".to_string()], - &["channel-b".to_string(), "channel-a".to_string()], - ), - vec![ - "channel-a".to_string(), - "channel-b".to_string(), - "channel-z".to_string(), - ] - ); - } - - #[test] - fn test_merge_channel_ids_returns_existing_when_discovered_is_empty() { - let existing = vec!["channel-a".to_string()]; - assert_eq!(merge_channel_ids(&existing, &[]), existing); - } - fn test_relay_agent(channel_ids: &[&str]) -> RelayAgentInfo { RelayAgentInfo { pubkey: "a".repeat(64), diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index d70c23c475..bc45b5e176 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -99,8 +99,9 @@ export function getMentionableAgentPubkeys({ eligibilityScope.type === "managed-only" ? false : eligibilityScope.type === "community" - ? // Community/global autocomplete has no single channel context, so we - // intentionally omit the live-membership fallback here. + ? // SECURITY: community/global autocomplete has no single channel context; + // omit live-membership fallback so relay agents are not shown based on + // channel-scoped membership hints alone (trust model: directory + shared channels). relayAgentIsSharedWithUser( agent, sharedChannelIds, From 3f8ad4a73446c902f1a9c5865e151cf2d355a9e8 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 11/18] style(desktop): rustfmt agent_discovery for tauri fmt-check (#5363) Unblocks desktop-tauri-fmt-check on Mac after L2 tie-break formatting. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- desktop/src-tauri/src/commands/agent_discovery.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cde9b63dc2..fc86c42543 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -98,8 +98,10 @@ fn collect_managed_agent_definitions( events: &[nostr::Event], expected_owners: &HashMap, ) -> HashMap)> { - let mut definitions: HashMap, u64, String)> = - HashMap::new(); + let mut definitions: HashMap< + String, + (crate::managed_agents::RespondTo, Vec, u64, String), + > = HashMap::new(); for event in events { let Some(agent_pubkey) = d_tag_from_event(event) else { tracing::warn!("list_relay_agents: skipping kind:30177 event without d-tag"); @@ -141,7 +143,9 @@ fn collect_managed_agent_definitions( } definitions .into_iter() - .map(|(agent_pubkey, (respond_to, allowlist, _, _))| (agent_pubkey, (respond_to, allowlist))) + .map(|(agent_pubkey, (respond_to, allowlist, _, _))| { + (agent_pubkey, (respond_to, allowlist)) + }) .collect() } From 10768cca7795e8f63482e81e1a4a93c157401f4f Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 12/18] fix(desktop): compile agent_discovery on current nostr API (#5363) Use event.tags.iter(), block-scoped match insert arm, and EventBuilder::custom_created_at in L2 tie-break test. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index fc86c42543..fe4bc4ca75 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -68,7 +68,7 @@ fn channel_ids_by_agent_from_membership_events( let Some(channel_id) = d_tag_from_event(event) else { continue; }; - for tag in &event.tags { + for tag in event.tags.iter() { let slice = tag.as_slice(); if slice.first().map(String::as_str) != Some("p") { continue; @@ -128,17 +128,19 @@ fn collect_managed_agent_definitions( if created_at < *existing_created_at || (created_at == *existing_created_at && event_id <= *existing_event_id) => { - continue + continue; + } + _ => { + definitions.insert( + agent_pubkey, + ( + content.respond_to, + content.respond_to_allowlist, + created_at, + event_id, + ), + ); } - _ => definitions.insert( - agent_pubkey, - ( - content.respond_to, - content.respond_to_allowlist, - created_at, - event_id, - ), - ), } } definitions @@ -1512,7 +1514,7 @@ mod tests { r#"{"name":"Scout","parallelism":1,"respond_to":"owner-only"}"#, ) .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) - .created_at(Timestamp::from(100)) + .custom_created_at(Timestamp::from(100)) .sign_with_keys(&owner_keys) .unwrap(); let newer = EventBuilder::new( @@ -1520,7 +1522,7 @@ mod tests { r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, ) .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) - .created_at(Timestamp::from(200)) + .custom_created_at(Timestamp::from(200)) .sign_with_keys(&owner_keys) .unwrap(); let expected_owners = From 170a11746e3f9bc7551ecc04648d9704f1b1fe64 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 13/18] fix(desktop): satisfy clippy manual_clamp and clone_on_copy (#5363) Unblocks desktop-tauri-clippy on Mac with -D warnings. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- desktop/src-tauri/src/commands/agent_discovery.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index fe4bc4ca75..d2531783e1 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -36,16 +36,14 @@ fn channel_membership_query_limit(agents: &[RelayAgentInfo]) -> usize { let channel_cardinality = channel_ids.len().max(agent_count); channel_cardinality .saturating_mul(2) - .max(RELAY_DEFAULT_QUERY_LIMIT) - .min(RELAY_MAX_QUERY_LIMIT) + .clamp(RELAY_DEFAULT_QUERY_LIMIT, RELAY_MAX_QUERY_LIMIT) } /// kind:30177 is replaceable by (author, kind, d): several events can share a d-tag. fn managed_agent_definition_query_limit(agent_count: usize) -> usize { agent_count .saturating_mul(4) - .max(RELAY_DEFAULT_QUERY_LIMIT) - .min(RELAY_MAX_QUERY_LIMIT) + .clamp(RELAY_DEFAULT_QUERY_LIMIT, RELAY_MAX_QUERY_LIMIT) } fn d_tag_from_event(event: &nostr::Event) -> Option { @@ -273,7 +271,7 @@ async fn enrich_relay_agents_from_relay( for agent in &mut agents { // Owner-verified kind:30177 overrides kind:10100 self-declared policy when present. if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { - agent.respond_to = Some(respond_to.clone()); + agent.respond_to = Some(*respond_to); agent.respond_to_allowlist = allowlist.clone(); } From 70b201e96645387cca48254e81a270e76d77d074 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 14/18] style(desktop): biome format agentAutocompleteEligibility (#5363) Unblocks pnpm check on Mac after L5 SECURITY comment edits. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../agents/lib/agentAutocompleteEligibility.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index bc45b5e176..41d05f2252 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -40,9 +40,7 @@ export function relayAgentIsSharedWithUser( // Relay directory channel_ids can lag behind live channel membership; callers // pass bot members from the active channel composer as a fresher signal. - return ( - channelMemberAgentPubkeys?.has(normalizePubkey(agent.pubkey)) === true - ); + return channelMemberAgentPubkeys?.has(normalizePubkey(agent.pubkey)) === true; } export function relayAgentCanRespondInChannel( @@ -102,11 +100,7 @@ export function getMentionableAgentPubkeys({ ? // SECURITY: community/global autocomplete has no single channel context; // omit live-membership fallback so relay agents are not shown based on // channel-scoped membership hints alone (trust model: directory + shared channels). - relayAgentIsSharedWithUser( - agent, - sharedChannelIds, - currentPubkey, - ) + relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) : relayAgentCanRespondInChannel( agent, eligibilityScope.channelId, From cc4b12329aa02e8f7efe74f19fdd63d2be583289 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 15/18] Extract relay enrich module to satisfy desktop file-size ratchet. Move kind:30177/39002 enrichment into agent_discovery/relay_enrich.rs and resolve channel-member hints via AgentEligibilityScope so useMentions.ts stays within its grandfathered line cap. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery.rs | 504 +---------------- .../commands/agent_discovery/relay_enrich.rs | 516 ++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 42 +- .../src/features/messages/lib/useMentions.ts | 20 +- 4 files changed, 562 insertions(+), 520 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index d2531783e1..3b69cb9bc1 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,294 +1,15 @@ -use std::collections::{HashMap, HashSet}; - -use buzz_core_pkg::kind::KIND_MANAGED_AGENT; use tauri::State; use crate::{ app_state::AppState, managed_agents::{ - agent_events::managed_agent_content_from_event, command_availability, - is_npm_global_install, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, - InstallRuntimeResult, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, + command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, + RelayAgentInfo, DEFAULT_ACP_COMMAND, }, - nostr_convert, relay::query_relay, }; -/// Relay HTTP `/query` default page size when `limit` is omitted. -const RELAY_DEFAULT_QUERY_LIMIT: usize = 100; -/// Relay hard cap for explicit `limit` (`DEFAULT_MAX_PAGE_LIMIT` in buzz-db). -const RELAY_MAX_QUERY_LIMIT: usize = 1000; - -/// kind:39002 returns one members-list event per channel, not per agent. -fn channel_membership_query_limit(agents: &[RelayAgentInfo]) -> usize { - let agent_count = agents.len(); - let sparse_10100 = agents.iter().all(|agent| agent.channel_ids.is_empty()); - if sparse_10100 { - // kind:10100 often omits channel_ids; cardinality is unknown — request max page. - return RELAY_MAX_QUERY_LIMIT; - } - - let mut channel_ids = HashSet::new(); - for agent in agents { - channel_ids.extend(agent.channel_ids.iter().cloned()); - } - // One 39002 event per channel where any agent is a member. - let channel_cardinality = channel_ids.len().max(agent_count); - channel_cardinality - .saturating_mul(2) - .clamp(RELAY_DEFAULT_QUERY_LIMIT, RELAY_MAX_QUERY_LIMIT) -} - -/// kind:30177 is replaceable by (author, kind, d): several events can share a d-tag. -fn managed_agent_definition_query_limit(agent_count: usize) -> usize { - agent_count - .saturating_mul(4) - .clamp(RELAY_DEFAULT_QUERY_LIMIT, RELAY_MAX_QUERY_LIMIT) -} - -fn d_tag_from_event(event: &nostr::Event) -> Option { - event.tags.iter().find_map(|tag| { - let slice = tag.as_slice(); - if slice.first().map(String::as_str) == Some("d") { - slice.get(1).filter(|value| !value.is_empty()).cloned() - } else { - None - } - }) -} - -fn channel_ids_by_agent_from_membership_events( - events: &[nostr::Event], - agent_pubkeys: &HashSet, -) -> HashMap> { - let mut by_agent: HashMap> = HashMap::new(); - for event in events { - let Some(channel_id) = d_tag_from_event(event) else { - continue; - }; - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(String::as_str) != Some("p") { - continue; - } - let Some(agent_pubkey) = slice.get(1).filter(|value| !value.is_empty()) else { - continue; - }; - if agent_pubkeys.contains(agent_pubkey) { - by_agent - .entry(agent_pubkey.clone()) - .or_default() - .insert(channel_id.clone()); - } - } - } - - let mut sorted: HashMap> = HashMap::with_capacity(by_agent.len()); - for (agent_pubkey, channel_ids) in by_agent { - let mut channel_ids: Vec = channel_ids.into_iter().collect(); - channel_ids.sort(); - sorted.insert(agent_pubkey, channel_ids); - } - sorted -} - -fn collect_managed_agent_definitions( - events: &[nostr::Event], - expected_owners: &HashMap, -) -> HashMap)> { - let mut definitions: HashMap< - String, - (crate::managed_agents::RespondTo, Vec, u64, String), - > = HashMap::new(); - for event in events { - let Some(agent_pubkey) = d_tag_from_event(event) else { - tracing::warn!("list_relay_agents: skipping kind:30177 event without d-tag"); - continue; - }; - let event_author = event.pubkey.to_hex(); - let Some(expected_owner) = expected_owners.get(&agent_pubkey) else { - continue; - }; - if event_author != *expected_owner { - continue; - } - let Ok(content) = managed_agent_content_from_event(event) else { - tracing::warn!( - agent_pubkey = %agent_pubkey, - "list_relay_agents: skipping unparsable kind:30177 content" - ); - continue; - }; - let created_at = event.created_at.as_secs(); - let event_id = event.id.to_hex(); - match definitions.get(&agent_pubkey) { - Some((_, _, existing_created_at, existing_event_id)) - if created_at < *existing_created_at - || (created_at == *existing_created_at && event_id <= *existing_event_id) => - { - continue; - } - _ => { - definitions.insert( - agent_pubkey, - ( - content.respond_to, - content.respond_to_allowlist, - created_at, - event_id, - ), - ); - } - } - } - definitions - .into_iter() - .map(|(agent_pubkey, (respond_to, allowlist, _, _))| { - (agent_pubkey, (respond_to, allowlist)) - }) - .collect() -} - -async fn fetch_agent_owner_pubkeys( - state: &AppState, - agent_pubkeys: &[String], -) -> HashMap { - if agent_pubkeys.is_empty() { - return HashMap::new(); - } - - match query_relay( - state, - &[serde_json::json!({ - "kinds": [0], - "authors": agent_pubkeys, - "limit": agent_pubkeys.len(), - })], - ) - .await - { - Ok(profile_events) => profile_events - .into_iter() - .filter_map(|event| { - nostr_convert::profile_valid_oa_owner_pubkey(&event) - .map(|owner| (event.pubkey.to_hex(), owner)) - }) - .collect(), - Err(error) => { - tracing::warn!( - error = %error, - "list_relay_agents: kind:0 owner lookup failed; skipping kind:30177 enrichment" - ); - HashMap::new() - } - } -} - -async fn fetch_managed_agent_definitions( - state: &AppState, - agent_pubkeys: &[String], - expected_owners: &HashMap, -) -> HashMap)> { - if agent_pubkeys.is_empty() || expected_owners.is_empty() { - return HashMap::new(); - } - - let owner_pubkeys: Vec = expected_owners - .values() - .cloned() - .collect::>() - .into_iter() - .collect(); - - let filter = serde_json::json!({ - "kinds": [KIND_MANAGED_AGENT], - "#d": agent_pubkeys, - "authors": owner_pubkeys, - "limit": managed_agent_definition_query_limit(agent_pubkeys.len()), - }); - - match query_relay(state, &[filter]).await { - Ok(definition_events) => { - collect_managed_agent_definitions(&definition_events, expected_owners) - } - Err(error) => { - tracing::warn!( - error = %error, - "list_relay_agents: kind:30177 enrich failed; continuing with kind:10100 only" - ); - HashMap::new() - } - } -} - -async fn fetch_channel_ids_by_agent( - state: &AppState, - agents: &[RelayAgentInfo], -) -> Option>> { - if agents.is_empty() { - return Some(HashMap::new()); - } - - let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); - let agent_pubkey_set: HashSet = agent_pubkeys.iter().cloned().collect(); - - match query_relay( - state, - &[serde_json::json!({ - "kinds": [39002], - "#p": agent_pubkeys, - "limit": channel_membership_query_limit(agents), - })], - ) - .await - { - Ok(membership_events) => Some(channel_ids_by_agent_from_membership_events( - &membership_events, - &agent_pubkey_set, - )), - Err(error) => { - tracing::warn!( - error = %error, - "list_relay_agents: kind:39002 membership enrich failed" - ); - None - } - } -} - -async fn enrich_relay_agents_from_relay( - state: &AppState, - mut agents: Vec, -) -> Vec { - let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); - let (expected_owners, channel_ids_by_agent) = tokio::join!( - fetch_agent_owner_pubkeys(state, &agent_pubkeys), - fetch_channel_ids_by_agent(state, &agents), - ); - let definitions = - fetch_managed_agent_definitions(state, &agent_pubkeys, &expected_owners).await; - - for agent in &mut agents { - // Owner-verified kind:30177 overrides kind:10100 self-declared policy when present. - if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { - agent.respond_to = Some(*respond_to); - agent.respond_to_allowlist = allowlist.clone(); - } - - if let Some(discovered_by_agent) = &channel_ids_by_agent { - // kind:39002 is authoritative for membership; keep 10100 hints on query failure - // or when an agent is absent from a truncated page (R5 M2). - let existing_channel_ids = agent.channel_ids.clone(); - agent.channel_ids = discovered_by_agent - .get(&agent.pubkey) - .cloned() - .unwrap_or(existing_channel_ids); - } - } - - agents -} - mod post_install_verification; fn active_installs() -> &'static std::sync::Mutex> { @@ -1278,6 +999,7 @@ fn build_install_command(command: &str) -> Result mod install_capture; mod install_exec; mod install_report; +mod relay_enrich; use install_exec::run_install_command_with_retry; use install_report::InstallReporter; @@ -1326,20 +1048,7 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result` the frontend expects. - let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - let agents: Vec = - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))?; - - // kind:10100 profiles are sparse: respond_to policy lives on kind:30177 and - // channel membership is on kind:39002. Merge both so Desktop mention - // eligibility (#4913 / #5363) sees the same data iOS already uses. - Ok(enrich_relay_agents_from_relay(state.inner(), agents).await) + relay_enrich::list_relay_agents_enriched(state.inner(), events).await } #[cfg(test)] @@ -1416,209 +1125,6 @@ mod tests { assert!(npm_eacces_hint(stderr, "npm install -g no-such-pkg").is_none()); } - // ── relay agent directory enrich helpers ────────────────────────────────── - - fn test_membership_event(channel_id: &str, member_pubkey: &str) -> nostr::Event { - use nostr::{EventBuilder, Keys, Kind, Tag}; - let keys = Keys::generate(); - EventBuilder::new(Kind::Custom(39_002), "") - .tags(vec![ - Tag::parse(["d", channel_id]).unwrap(), - Tag::parse(["p", member_pubkey, "", "member"]).unwrap(), - ]) - .sign_with_keys(&keys) - .unwrap() - } - - fn test_managed_agent_definition_event( - agent_pubkey: &str, - respond_to: &str, - author_keys: &nostr::Keys, - ) -> nostr::Event { - use nostr::{EventBuilder, Kind, Tag}; - let content = serde_json::json!({ - "name": "Scout", - "parallelism": 1, - "respond_to": respond_to, - }); - EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content.to_string()) - .tags(vec![Tag::parse(["d", agent_pubkey]).unwrap()]) - .sign_with_keys(author_keys) - .unwrap() - } - - #[test] - fn test_d_tag_from_event_reads_first_non_empty_d_tag() { - let event = test_membership_event("273e2bad-b694-4a0e-bc2b-aefcc7d027bb", &"a".repeat(64)); - assert_eq!( - d_tag_from_event(&event).as_deref(), - Some("273e2bad-b694-4a0e-bc2b-aefcc7d027bb") - ); - } - - #[test] - fn test_d_tag_from_event_returns_none_without_d_tag() { - use nostr::{EventBuilder, Keys, Kind}; - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(39_002), "") - .sign_with_keys(&keys) - .unwrap(); - assert!(d_tag_from_event(&event).is_none()); - } - - #[test] - fn test_channel_ids_by_agent_from_membership_events_groups_by_p_tag() { - let agent_a = "a".repeat(64); - let agent_b = "b".repeat(64); - let events = vec![ - test_membership_event("channel-a", &agent_a), - test_membership_event("channel-b", &agent_b), - test_membership_event("channel-c", &agent_a), - ]; - let agent_pubkeys = HashSet::from([agent_a.clone(), agent_b.clone()]); - let by_agent = channel_ids_by_agent_from_membership_events(&events, &agent_pubkeys); - assert_eq!( - by_agent.get(&agent_a), - Some(&vec!["channel-a".to_string(), "channel-c".to_string()]) - ); - assert_eq!(by_agent.get(&agent_b), Some(&vec!["channel-b".to_string()])); - } - - #[test] - fn test_collect_managed_agent_definitions_indexes_by_d_tag() { - use nostr::Keys; - let agent_pubkey = "a".repeat(64); - let owner_keys = Keys::generate(); - let events = vec![test_managed_agent_definition_event( - &agent_pubkey, - "anyone", - &owner_keys, - )]; - let expected_owners = - HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); - let definitions = collect_managed_agent_definitions(&events, &expected_owners); - let (respond_to, allowlist) = definitions.get(&agent_pubkey).unwrap(); - assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); - assert!(allowlist.is_empty()); - } - - #[test] - fn test_collect_managed_agent_definitions_prefers_newest_created_at() { - use nostr::{EventBuilder, Kind, Tag, Timestamp}; - let agent_pubkey = "a".repeat(64); - let owner_keys = nostr::Keys::generate(); - let older = EventBuilder::new( - Kind::Custom(KIND_MANAGED_AGENT as u16), - r#"{"name":"Scout","parallelism":1,"respond_to":"owner-only"}"#, - ) - .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) - .custom_created_at(Timestamp::from(100)) - .sign_with_keys(&owner_keys) - .unwrap(); - let newer = EventBuilder::new( - Kind::Custom(KIND_MANAGED_AGENT as u16), - r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, - ) - .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) - .custom_created_at(Timestamp::from(200)) - .sign_with_keys(&owner_keys) - .unwrap(); - let expected_owners = - HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); - let definitions = collect_managed_agent_definitions(&[older, newer], &expected_owners); - let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); - assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); - } - - #[test] - fn test_collect_managed_agent_definitions_filters_unexpected_authors() { - use nostr::Keys; - let agent_pubkey = "a".repeat(64); - let owner_keys = Keys::generate(); - let spoof_keys = Keys::generate(); - let expected_owners = - HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); - let legitimate = test_managed_agent_definition_event(&agent_pubkey, "anyone", &owner_keys); - let spoofed = test_managed_agent_definition_event(&agent_pubkey, "owner-only", &spoof_keys); - let definitions = - collect_managed_agent_definitions(&[legitimate, spoofed], &expected_owners); - let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); - assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); - } - - #[test] - fn test_collect_managed_agent_definitions_skips_when_owner_unverified() { - use nostr::Keys; - let agent_pubkey = "a".repeat(64); - let owner_keys = Keys::generate(); - let events = vec![test_managed_agent_definition_event( - &agent_pubkey, - "anyone", - &owner_keys, - )]; - let definitions = collect_managed_agent_definitions(&events, &HashMap::new()); - assert!(definitions.is_empty()); - } - - fn test_relay_agent(channel_ids: &[&str]) -> RelayAgentInfo { - RelayAgentInfo { - pubkey: "a".repeat(64), - name: "Scout".to_string(), - agent_type: "agent".to_string(), - channels: vec![], - channel_ids: channel_ids.iter().map(|id| (*id).to_string()).collect(), - capabilities: vec![], - status: "offline".to_string(), - respond_to: None, - respond_to_allowlist: vec![], - } - } - - #[test] - fn test_channel_membership_query_limit_scales_with_channel_cardinality() { - let agents = vec![ - test_relay_agent(&["channel-a", "channel-b", "channel-c"]), - test_relay_agent(&["channel-d"]), - ]; - assert_eq!( - channel_membership_query_limit(&agents), - RELAY_DEFAULT_QUERY_LIMIT - ); - } - - #[test] - fn test_channel_membership_query_limit_scales_above_relay_default() { - let channel_ids: Vec = (0..51).map(|i| format!("channel-{i}")).collect(); - let channel_refs: Vec<&str> = channel_ids.iter().map(String::as_str).collect(); - let agents = vec![test_relay_agent(&channel_refs)]; - assert_eq!(channel_membership_query_limit(&agents), 102); - } - - #[test] - fn test_channel_membership_query_limit_uses_max_page_when_10100_sparse() { - assert_eq!( - channel_membership_query_limit(&[test_relay_agent(&[])]), - RELAY_MAX_QUERY_LIMIT - ); - } - - #[test] - fn test_managed_agent_definition_query_limit_allows_multiple_authors_per_d_tag() { - assert_eq!( - managed_agent_definition_query_limit(5), - RELAY_DEFAULT_QUERY_LIMIT - ); - assert_eq!( - managed_agent_definition_query_limit(1), - RELAY_DEFAULT_QUERY_LIMIT - ); - } - - #[test] - fn test_managed_agent_definition_query_limit_scales_above_relay_default() { - assert_eq!(managed_agent_definition_query_limit(26), 104); - } - // ── adapter_needs_install (codex version gate) ──────────────────────────── /// plan_adapter_install is the pure install-plan seam used by diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs new file mode 100644 index 0000000000..648a408332 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs @@ -0,0 +1,516 @@ +//! Enrich kind:10100 relay agent directory entries with kind:30177 policy and +//! kind:39002 channel membership (#4913 / #5363). + +use std::collections::{HashMap, HashSet}; + +use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_events::managed_agent_content_from_event, RelayAgentInfo, + }, + nostr_convert, + relay::query_relay, +}; + +/// Relay HTTP `/query` default page size when `limit` is omitted. +const RELAY_DEFAULT_QUERY_LIMIT: usize = 100; +/// Relay hard cap for explicit `limit` (`DEFAULT_MAX_PAGE_LIMIT` in buzz-db). +const RELAY_MAX_QUERY_LIMIT: usize = 1000; + +/// kind:39002 returns one members-list event per channel, not per agent. +fn channel_membership_query_limit(agents: &[RelayAgentInfo]) -> usize { + let agent_count = agents.len(); + let sparse_10100 = agents.iter().all(|agent| agent.channel_ids.is_empty()); + if sparse_10100 { + // kind:10100 often omits channel_ids; cardinality is unknown — request max page. + return RELAY_MAX_QUERY_LIMIT; + } + + let mut channel_ids = HashSet::new(); + for agent in agents { + channel_ids.extend(agent.channel_ids.iter().cloned()); + } + // One 39002 event per channel where any agent is a member. + let channel_cardinality = channel_ids.len().max(agent_count); + channel_cardinality + .saturating_mul(2) + .clamp(RELAY_DEFAULT_QUERY_LIMIT, RELAY_MAX_QUERY_LIMIT) +} + +/// kind:30177 is replaceable by (author, kind, d): several events can share a d-tag. +fn managed_agent_definition_query_limit(agent_count: usize) -> usize { + agent_count + .saturating_mul(4) + .clamp(RELAY_DEFAULT_QUERY_LIMIT, RELAY_MAX_QUERY_LIMIT) +} + +fn d_tag_from_event(event: &nostr::Event) -> Option { + event.tags.iter().find_map(|tag| { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) == Some("d") { + slice.get(1).filter(|value| !value.is_empty()).cloned() + } else { + None + } + }) +} + +fn channel_ids_by_agent_from_membership_events( + events: &[nostr::Event], + agent_pubkeys: &HashSet, +) -> HashMap> { + let mut by_agent: HashMap> = HashMap::new(); + for event in events { + let Some(channel_id) = d_tag_from_event(event) else { + continue; + }; + for tag in event.tags.iter() { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) != Some("p") { + continue; + } + let Some(agent_pubkey) = slice.get(1).filter(|value| !value.is_empty()) else { + continue; + }; + if agent_pubkeys.contains(agent_pubkey) { + by_agent + .entry(agent_pubkey.clone()) + .or_default() + .insert(channel_id.clone()); + } + } + } + + let mut sorted: HashMap> = HashMap::with_capacity(by_agent.len()); + for (agent_pubkey, channel_ids) in by_agent { + let mut channel_ids: Vec = channel_ids.into_iter().collect(); + channel_ids.sort(); + sorted.insert(agent_pubkey, channel_ids); + } + sorted +} + +fn collect_managed_agent_definitions( + events: &[nostr::Event], + expected_owners: &HashMap, +) -> HashMap)> { + let mut definitions: HashMap< + String, + (crate::managed_agents::RespondTo, Vec, u64, String), + > = HashMap::new(); + for event in events { + let Some(agent_pubkey) = d_tag_from_event(event) else { + tracing::warn!("list_relay_agents: skipping kind:30177 event without d-tag"); + continue; + }; + let event_author = event.pubkey.to_hex(); + let Some(expected_owner) = expected_owners.get(&agent_pubkey) else { + continue; + }; + if event_author != *expected_owner { + continue; + } + let Ok(content) = managed_agent_content_from_event(event) else { + tracing::warn!( + agent_pubkey = %agent_pubkey, + "list_relay_agents: skipping unparsable kind:30177 content" + ); + continue; + }; + let created_at = event.created_at.as_secs(); + let event_id = event.id.to_hex(); + match definitions.get(&agent_pubkey) { + Some((_, _, existing_created_at, existing_event_id)) + if created_at < *existing_created_at + || (created_at == *existing_created_at && event_id <= *existing_event_id) => + { + continue; + } + _ => { + definitions.insert( + agent_pubkey, + ( + content.respond_to, + content.respond_to_allowlist, + created_at, + event_id, + ), + ); + } + } + } + definitions + .into_iter() + .map(|(agent_pubkey, (respond_to, allowlist, _, _))| { + (agent_pubkey, (respond_to, allowlist)) + }) + .collect() +} + +async fn fetch_agent_owner_pubkeys( + state: &AppState, + agent_pubkeys: &[String], +) -> HashMap { + if agent_pubkeys.is_empty() { + return HashMap::new(); + } + + match query_relay( + state, + &[serde_json::json!({ + "kinds": [0], + "authors": agent_pubkeys, + "limit": agent_pubkeys.len(), + })], + ) + .await + { + Ok(profile_events) => profile_events + .into_iter() + .filter_map(|event| { + nostr_convert::profile_valid_oa_owner_pubkey(&event) + .map(|owner| (event.pubkey.to_hex(), owner)) + }) + .collect(), + Err(error) => { + tracing::warn!( + error = %error, + "list_relay_agents: kind:0 owner lookup failed; skipping kind:30177 enrichment" + ); + HashMap::new() + } + } +} + +async fn fetch_managed_agent_definitions( + state: &AppState, + agent_pubkeys: &[String], + expected_owners: &HashMap, +) -> HashMap)> { + if agent_pubkeys.is_empty() || expected_owners.is_empty() { + return HashMap::new(); + } + + let owner_pubkeys: Vec = expected_owners + .values() + .cloned() + .collect::>() + .into_iter() + .collect(); + + let filter = serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + "#d": agent_pubkeys, + "authors": owner_pubkeys, + "limit": managed_agent_definition_query_limit(agent_pubkeys.len()), + }); + + match query_relay(state, &[filter]).await { + Ok(definition_events) => { + collect_managed_agent_definitions(&definition_events, expected_owners) + } + Err(error) => { + tracing::warn!( + error = %error, + "list_relay_agents: kind:30177 enrich failed; continuing with kind:10100 only" + ); + HashMap::new() + } + } +} + +async fn fetch_channel_ids_by_agent( + state: &AppState, + agents: &[RelayAgentInfo], +) -> Option>> { + if agents.is_empty() { + return Some(HashMap::new()); + } + + let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); + let agent_pubkey_set: HashSet = agent_pubkeys.iter().cloned().collect(); + + match query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#p": agent_pubkeys, + "limit": channel_membership_query_limit(agents), + })], + ) + .await + { + Ok(membership_events) => Some(channel_ids_by_agent_from_membership_events( + &membership_events, + &agent_pubkey_set, + )), + Err(error) => { + tracing::warn!( + error = %error, + "list_relay_agents: kind:39002 membership enrich failed" + ); + None + } + } +} + +pub(super) async fn list_relay_agents_enriched( + state: &AppState, + events: Vec, +) -> Result, String> { + // 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") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let agents: Vec = + serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))?; + + // kind:10100 profiles are sparse: respond_to policy lives on kind:30177 and + // channel membership is on kind:39002. Merge both so Desktop mention + // eligibility (#4913 / #5363) sees the same data iOS already uses. + Ok(enrich_relay_agents_from_relay(state, agents).await) +} + +async fn enrich_relay_agents_from_relay( + state: &AppState, + mut agents: Vec, +) -> Vec { + let agent_pubkeys: Vec = agents.iter().map(|agent| agent.pubkey.clone()).collect(); + let (expected_owners, channel_ids_by_agent) = tokio::join!( + fetch_agent_owner_pubkeys(state, &agent_pubkeys), + fetch_channel_ids_by_agent(state, &agents), + ); + let definitions = + fetch_managed_agent_definitions(state, &agent_pubkeys, &expected_owners).await; + + for agent in &mut agents { + // Owner-verified kind:30177 overrides kind:10100 self-declared policy when present. + if let Some((respond_to, allowlist)) = definitions.get(&agent.pubkey) { + agent.respond_to = Some(*respond_to); + agent.respond_to_allowlist = allowlist.clone(); + } + + if let Some(discovered_by_agent) = &channel_ids_by_agent { + // kind:39002 is authoritative for membership; keep 10100 hints on query failure + // or when an agent is absent from a truncated page (R5 M2). + let existing_channel_ids = agent.channel_ids.clone(); + agent.channel_ids = discovered_by_agent + .get(&agent.pubkey) + .cloned() + .unwrap_or(existing_channel_ids); + } + } + + agents +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_membership_event(channel_id: &str, member_pubkey: &str) -> nostr::Event { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(39_002), "") + .tags(vec![ + Tag::parse(["d", channel_id]).unwrap(), + Tag::parse(["p", member_pubkey, "", "member"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap() + } + + fn test_managed_agent_definition_event( + agent_pubkey: &str, + respond_to: &str, + author_keys: &nostr::Keys, + ) -> nostr::Event { + use nostr::{EventBuilder, Kind, Tag}; + let content = serde_json::json!({ + "name": "Scout", + "parallelism": 1, + "respond_to": respond_to, + }); + EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content.to_string()) + .tags(vec![Tag::parse(["d", agent_pubkey]).unwrap()]) + .sign_with_keys(author_keys) + .unwrap() + } + + #[test] + fn test_d_tag_from_event_reads_first_non_empty_d_tag() { + let event = test_membership_event("273e2bad-b694-4a0e-bc2b-aefcc7d027bb", &"a".repeat(64)); + assert_eq!( + d_tag_from_event(&event).as_deref(), + Some("273e2bad-b694-4a0e-bc2b-aefcc7d027bb") + ); + } + + #[test] + fn test_d_tag_from_event_returns_none_without_d_tag() { + use nostr::{EventBuilder, Keys, Kind}; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(39_002), "") + .sign_with_keys(&keys) + .unwrap(); + assert!(d_tag_from_event(&event).is_none()); + } + + #[test] + fn test_channel_ids_by_agent_from_membership_events_groups_by_p_tag() { + let agent_a = "a".repeat(64); + let agent_b = "b".repeat(64); + let events = vec![ + test_membership_event("channel-a", &agent_a), + test_membership_event("channel-b", &agent_b), + test_membership_event("channel-c", &agent_a), + ]; + let agent_pubkeys = HashSet::from([agent_a.clone(), agent_b.clone()]); + let by_agent = channel_ids_by_agent_from_membership_events(&events, &agent_pubkeys); + assert_eq!( + by_agent.get(&agent_a), + Some(&vec!["channel-a".to_string(), "channel-c".to_string()]) + ); + assert_eq!(by_agent.get(&agent_b), Some(&vec!["channel-b".to_string()])); + } + + #[test] + fn test_collect_managed_agent_definitions_indexes_by_d_tag() { + use nostr::Keys; + let agent_pubkey = "a".repeat(64); + let owner_keys = Keys::generate(); + let events = vec![test_managed_agent_definition_event( + &agent_pubkey, + "anyone", + &owner_keys, + )]; + let expected_owners = + HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); + let definitions = collect_managed_agent_definitions(&events, &expected_owners); + let (respond_to, allowlist) = definitions.get(&agent_pubkey).unwrap(); + assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); + assert!(allowlist.is_empty()); + } + + #[test] + fn test_collect_managed_agent_definitions_prefers_newest_created_at() { + use nostr::{EventBuilder, Kind, Tag, Timestamp}; + let agent_pubkey = "a".repeat(64); + let owner_keys = nostr::Keys::generate(); + let older = EventBuilder::new( + Kind::Custom(KIND_MANAGED_AGENT as u16), + r#"{"name":"Scout","parallelism":1,"respond_to":"owner-only"}"#, + ) + .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) + .custom_created_at(Timestamp::from(100)) + .sign_with_keys(&owner_keys) + .unwrap(); + let newer = EventBuilder::new( + Kind::Custom(KIND_MANAGED_AGENT as u16), + r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, + ) + .tags(vec![Tag::parse(["d", &agent_pubkey]).unwrap()]) + .custom_created_at(Timestamp::from(200)) + .sign_with_keys(&owner_keys) + .unwrap(); + let expected_owners = + HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); + let definitions = collect_managed_agent_definitions(&[older, newer], &expected_owners); + let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); + assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); + } + + #[test] + fn test_collect_managed_agent_definitions_filters_unexpected_authors() { + use nostr::Keys; + let agent_pubkey = "a".repeat(64); + let owner_keys = Keys::generate(); + let spoof_keys = Keys::generate(); + let expected_owners = + HashMap::from([(agent_pubkey.clone(), owner_keys.public_key().to_hex())]); + let legitimate = test_managed_agent_definition_event(&agent_pubkey, "anyone", &owner_keys); + let spoofed = test_managed_agent_definition_event(&agent_pubkey, "owner-only", &spoof_keys); + let definitions = + collect_managed_agent_definitions(&[legitimate, spoofed], &expected_owners); + let (respond_to, _) = definitions.get(&agent_pubkey).unwrap(); + assert_eq!(*respond_to, crate::managed_agents::RespondTo::Anyone); + } + + #[test] + fn test_collect_managed_agent_definitions_skips_when_owner_unverified() { + use nostr::Keys; + let agent_pubkey = "a".repeat(64); + let owner_keys = Keys::generate(); + let events = vec![test_managed_agent_definition_event( + &agent_pubkey, + "anyone", + &owner_keys, + )]; + let definitions = collect_managed_agent_definitions(&events, &HashMap::new()); + assert!(definitions.is_empty()); + } + + fn test_relay_agent(channel_ids: &[&str]) -> RelayAgentInfo { + RelayAgentInfo { + pubkey: "a".repeat(64), + name: "Scout".to_string(), + agent_type: "agent".to_string(), + channels: vec![], + channel_ids: channel_ids.iter().map(|id| (*id).to_string()).collect(), + capabilities: vec![], + status: "offline".to_string(), + respond_to: None, + respond_to_allowlist: vec![], + } + } + + #[test] + fn test_channel_membership_query_limit_scales_with_channel_cardinality() { + let agents = vec![ + test_relay_agent(&["channel-a", "channel-b", "channel-c"]), + test_relay_agent(&["channel-d"]), + ]; + assert_eq!( + channel_membership_query_limit(&agents), + RELAY_DEFAULT_QUERY_LIMIT + ); + } + + #[test] + fn test_channel_membership_query_limit_scales_above_relay_default() { + let channel_ids: Vec = (0..51).map(|i| format!("channel-{i}")).collect(); + let channel_refs: Vec<&str> = channel_ids.iter().map(String::as_str).collect(); + let agents = vec![test_relay_agent(&channel_refs)]; + assert_eq!(channel_membership_query_limit(&agents), 102); + } + + #[test] + fn test_channel_membership_query_limit_uses_max_page_when_10100_sparse() { + assert_eq!( + channel_membership_query_limit(&[test_relay_agent(&[])]), + RELAY_MAX_QUERY_LIMIT + ); + } + + #[test] + fn test_managed_agent_definition_query_limit_allows_multiple_authors_per_d_tag() { + assert_eq!( + managed_agent_definition_query_limit(5), + RELAY_DEFAULT_QUERY_LIMIT + ); + assert_eq!( + managed_agent_definition_query_limit(1), + RELAY_DEFAULT_QUERY_LIMIT + ); + } + + #[test] + fn test_managed_agent_definition_query_limit_scales_above_relay_default() { + assert_eq!(managed_agent_definition_query_limit(26), 104); + } +} diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 41d05f2252..7c90035b63 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,6 +1,25 @@ -import type { Channel, RelayAgent } from "@/shared/api/types"; +import type { Channel, ChannelMember, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function computeChannelMemberAgentPubkeys( + mentionChannelId: string | null, + members: readonly ChannelMember[] | undefined, + membersLoading: boolean, + hasExternalMembers: boolean, +): ReadonlySet | undefined { + if (!mentionChannelId) { + return undefined; + } + if (!hasExternalMembers && membersLoading) { + return new Set(); + } + return new Set( + (members ?? []) + .filter((member) => member.isAgent === true || member.role === "bot") + .map((member) => normalizePubkey(member.pubkey)), + ); +} + export function getSharedChannelIds(channels: readonly Channel[] | undefined) { return new Set( (channels ?? []) @@ -70,7 +89,13 @@ export function relayAgentCanRespondInChannel( export type AgentEligibilityScope = | { type: "community" } - | { type: "channel"; channelId: string } + | { + type: "channel"; + channelId: string; + channelMembers?: readonly ChannelMember[]; + membersLoading?: boolean; + hasExternalMembers?: boolean; + } | { type: "managed-only" }; export function getMentionableAgentPubkeys({ @@ -88,6 +113,17 @@ export function getMentionableAgentPubkeys({ relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; }) { + const resolvedChannelMemberAgentPubkeys = + channelMemberAgentPubkeys ?? + (eligibilityScope.type === "channel" + ? computeChannelMemberAgentPubkeys( + eligibilityScope.channelId, + eligibilityScope.channelMembers, + eligibilityScope.membersLoading ?? false, + eligibilityScope.hasExternalMembers ?? false, + ) + : undefined); + const pubkeys = new Set( [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), ); @@ -105,7 +141,7 @@ export function getMentionableAgentPubkeys({ agent, eligibilityScope.channelId, currentPubkey, - channelMemberAgentPubkeys, + resolvedChannelMemberAgentPubkeys, ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index a6f1de262e..5aa83e0abb 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -197,35 +197,19 @@ export function useMentions( const mentionChannelId = isAgentMentionChannelType(options?.channelType) ? channelId : null; - const channelMemberAgentPubkeys = React.useMemo(() => { - if (!mentionChannelId) { - return undefined; - } - if (externalMembers === undefined && membersQuery.isLoading) { - return new Set(); - } - return new Set( - (members ?? []) - .filter((member) => member.isAgent === true || member.role === "bot") - .map((member) => normalizePubkey(member.pubkey)), - ); - }, [externalMembers, members, mentionChannelId, membersQuery.isLoading]); const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ - channelMemberAgentPubkeys, currentPubkey, eligibilityScope: mentionChannelId - ? { type: "channel", channelId: mentionChannelId } + ? { type: "channel", channelId: mentionChannelId, channelMembers: members, membersLoading: membersQuery.isLoading, hasExternalMembers: externalMembers !== undefined } : { type: "managed-only" }, managedAgentPubkeys, relayAgents: relayAgentsQuery.data, sharedChannelIds, }), [ - channelMemberAgentPubkeys, - currentPubkey, - managedAgentPubkeys, + currentPubkey, externalMembers, managedAgentPubkeys, members, membersQuery.isLoading, mentionChannelId, relayAgentsQuery.data, sharedChannelIds, From 340d9d0cd75389439515bd08cfe473b0e5fe440e Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 16/18] Fix rustfmt in relay_enrich.rs import block. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src-tauri/src/commands/agent_discovery/relay_enrich.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs index 648a408332..40ad2f44dd 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_enrich.rs @@ -7,9 +7,7 @@ use buzz_core_pkg::kind::KIND_MANAGED_AGENT; use crate::{ app_state::AppState, - managed_agents::{ - agent_events::managed_agent_content_from_event, RelayAgentInfo, - }, + managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo}, nostr_convert, relay::query_relay, }; From 8df3742e65ec8bd61e9aa3a15b4c2a3c3219f04d Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 17/18] Fix biome format for channel mention eligibility without growing useMentions. Move scope construction to getMentionableAgentPubkeysFromComposer and a small hook so useMentions.ts stays within the file-size ratchet. Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.ts | 36 ++++++++++++++ .../lib/useComposerMentionableAgentPubkeys.ts | 47 +++++++++++++++++++ .../src/features/messages/lib/useMentions.ts | 39 ++++++--------- 3 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 desktop/src/features/messages/lib/useComposerMentionableAgentPubkeys.ts diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 7c90035b63..b2ac444ecd 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -151,6 +151,42 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +export function getMentionableAgentPubkeysFromComposer({ + mentionChannelId, + channelMembers, + membersLoading, + hasExternalMembers, + currentPubkey, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, +}: { + mentionChannelId: string | null; + channelMembers: readonly ChannelMember[] | undefined; + membersLoading: boolean; + hasExternalMembers: boolean; + currentPubkey?: string | null; + managedAgentPubkeys: Iterable; + relayAgents: readonly RelayAgent[] | undefined; + sharedChannelIds: ReadonlySet; +}) { + return getMentionableAgentPubkeys({ + currentPubkey, + eligibilityScope: mentionChannelId + ? { + type: "channel", + channelId: mentionChannelId, + channelMembers, + membersLoading, + hasExternalMembers, + } + : { type: "managed-only" }, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, + }); +} + export function isAgentIdentityInAllowedList( candidate: { isAgent?: boolean; pubkey: string }, allowedAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/messages/lib/useComposerMentionableAgentPubkeys.ts b/desktop/src/features/messages/lib/useComposerMentionableAgentPubkeys.ts new file mode 100644 index 0000000000..b3c8315836 --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerMentionableAgentPubkeys.ts @@ -0,0 +1,47 @@ +import * as React from "react"; +import { getMentionableAgentPubkeysFromComposer } from "@/features/agents/lib/agentAutocompleteEligibility"; +import type { ChannelMember, RelayAgent } from "@/shared/api/types"; + +export function useComposerMentionableAgentPubkeys({ + mentionChannelId, + channelMembers, + membersLoading, + hasExternalMembers, + currentPubkey, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, +}: { + mentionChannelId: string | null; + channelMembers: readonly ChannelMember[] | undefined; + membersLoading: boolean; + hasExternalMembers: boolean; + currentPubkey?: string | null; + managedAgentPubkeys: Iterable; + relayAgents: readonly RelayAgent[] | undefined; + sharedChannelIds: ReadonlySet; +}) { + return React.useMemo( + () => + getMentionableAgentPubkeysFromComposer({ + mentionChannelId, + channelMembers, + membersLoading, + hasExternalMembers, + currentPubkey, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, + }), + [ + channelMembers, + currentPubkey, + hasExternalMembers, + managedAgentPubkeys, + membersLoading, + mentionChannelId, + relayAgents, + sharedChannelIds, + ], + ); +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 5aa83e0abb..ead9f0ea97 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -11,11 +11,11 @@ import { } from "@/features/channels/hooks"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import { useComposerMentionableAgentPubkeys } from "./useComposerMentionableAgentPubkeys"; import { coalesceAgentAutocompleteCandidates, coalesceAutocompleteCandidatesByKey, filterCachedAgentSuggestions, - getMentionableAgentPubkeys, getSharedChannelIds, isAgentIdentityInAllowedList, isAgentMentionChannelType, @@ -197,24 +197,16 @@ export function useMentions( const mentionChannelId = isAgentMentionChannelType(options?.channelType) ? channelId : null; - const mentionableAgentPubkeys = React.useMemo( - () => - getMentionableAgentPubkeys({ - currentPubkey, - eligibilityScope: mentionChannelId - ? { type: "channel", channelId: mentionChannelId, channelMembers: members, membersLoading: membersQuery.isLoading, hasExternalMembers: externalMembers !== undefined } - : { type: "managed-only" }, - managedAgentPubkeys, - relayAgents: relayAgentsQuery.data, - sharedChannelIds, - }), - [ - currentPubkey, externalMembers, managedAgentPubkeys, members, membersQuery.isLoading, - mentionChannelId, - relayAgentsQuery.data, - sharedChannelIds, - ], - ); + const mentionableAgentPubkeys = useComposerMentionableAgentPubkeys({ + mentionChannelId, + channelMembers: members, + membersLoading: membersQuery.isLoading, + hasExternalMembers: externalMembers !== undefined, + currentPubkey, + managedAgentPubkeys, + relayAgents: relayAgentsQuery.data, + sharedChannelIds, + }); const personaNameByPubkey = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personas = personasQuery.data ?? []; @@ -228,7 +220,6 @@ export function useMentions( } return lookup; }, [managedAgentsQuery.data, personasQuery.data]); - const knownAgentPubkeys = mentionableAgentPubkeys; const activePersonas = React.useMemo( () => (personasQuery.data ?? []).filter((persona) => persona.isActive), [personasQuery.data], @@ -646,7 +637,7 @@ export function useMentions( suggestion.kind === "team" || suggestion.isAgent === true || (suggestion.pubkey - ? knownAgentPubkeys.has(normalizePubkey(suggestion.pubkey)) + ? mentionableAgentPubkeys.has(normalizePubkey(suggestion.pubkey)) : false); if (isAgentMention) { setSelectedAgentMentionNames((current) => { @@ -675,7 +666,7 @@ export function useMentions( insertText, }; }, - [knownAgentPubkeys, mentionStartIndex], + [mentionableAgentPubkeys, mentionStartIndex], ); const registerMentionPubkey = React.useCallback( @@ -749,8 +740,8 @@ export function useMentions( ); const isAgentPubkey = React.useCallback( - (pubkey: string): boolean => knownAgentPubkeys.has(normalizePubkey(pubkey)), - [knownAgentPubkeys], + (pubkey: string): boolean => mentionableAgentPubkeys.has(normalizePubkey(pubkey)), + [mentionableAgentPubkeys], ); const isManagedAgentPubkey = React.useCallback( (pubkey: string): boolean => From 3eaadbb2de546e2165d298a766194ee4cc4b9304 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:28 -0500 Subject: [PATCH 18/18] style(desktop): biome format useMentions.ts Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- desktop/src/features/messages/lib/useMentions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index ead9f0ea97..b8df050f3c 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -740,7 +740,8 @@ export function useMentions( ); const isAgentPubkey = React.useCallback( - (pubkey: string): boolean => mentionableAgentPubkeys.has(normalizePubkey(pubkey)), + (pubkey: string): boolean => + mentionableAgentPubkeys.has(normalizePubkey(pubkey)), [mentionableAgentPubkeys], ); const isManagedAgentPubkey = React.useCallback(