From 3b7d6d4d223ae4d1db65914df9a13a31107715e4 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Tue, 7 Jul 2026 17:22:23 -0500 Subject: [PATCH 1/2] Hide non-invocable member agents from @-mention autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents you can't invoke (e.g. Fizz, owned by another team) still appeared in the composer's @-mention list when they were added to the channel as members. The existing invocability filter in useMentions only applied to non-member agents, so member agents bypassed it entirely. Extract the decision into a pure helper, shouldHideAgentFromMentions, and apply it to all agent candidates. Option B semantics: hide a member agent only when we have an explicit not-invocable signal — a relay directory (kind:10100) entry that excludes the current user. When invocability is unknown (agent not yet in the directory / still loading), keep showing it so we never hide an agent we can't confirm is non-invocable. This mirrors the backend gate (author_allowed in buzz-acp), so the autocomplete stays consistent with server-side enforcement. Adds unit tests covering every branch, including the two Option B cases and npub/hex pubkey normalization. --- .../lib/agentAutocompleteEligibility.test.mjs | 82 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 25 ++++++ .../src/features/messages/lib/useMentions.ts | 21 ++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 3aa2cd9d9d..f04d722e74 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -6,6 +6,7 @@ import { getMentionableAgentPubkeys, getSharedChannelIds, relayAgentIsSharedWithUser, + shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; const CURRENT_PUBKEY = "a".repeat(64); @@ -134,6 +135,87 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); +test("shouldHideAgentFromMentions: never hides non-agents", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: false, + isMember: false, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set([PUB_A]), + }), + false, + ); +}); + +test("shouldHideAgentFromMentions: shows invocable agents even when non-member", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: false, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set([PUB_A]), + directoryAgentPubkeys: new Set([PUB_A]), + }), + false, + ); +}); + +test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: false, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set(), + }), + true, + ); +}); + +test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set([PUB_A]), + }), + true, + ); +}); + +test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set(), + }), + false, + ); +}); + +test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { + const mixedCase = "Ab".repeat(32); + const normalized = mixedCase.toLowerCase(); + + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: mixedCase, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set([normalized]), + }), + true, + ); +}); + test("coalesceAgentAutocompleteCandidates: merges agents with the same persona id", () => { const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" }); const second = makeAgent({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index b3a57927a6..0c7cb1cd50 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,6 +54,31 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +export function shouldHideAgentFromMentions({ + isAgent, + isMember, + pubkey, + mentionableAgentPubkeys, + directoryAgentPubkeys, +}: { + isAgent: boolean; + isMember: boolean; + pubkey: string; + mentionableAgentPubkeys: ReadonlySet; + directoryAgentPubkeys: ReadonlySet; +}) { + if (!isAgent) return false; + const normalized = normalizePubkey(pubkey); + // Invocable => always show. + if (mentionableAgentPubkeys.has(normalized)) return false; + // Non-member, non-invocable => hide (preserves prior behavior). + if (!isMember) return true; + // Member (Option B): hide only when we have an explicit not-invocable + // signal — a relay directory (kind:10100) entry that excludes us. + // Unknown invocability (not in directory) => show. + return directoryAgentPubkeys.has(normalized); +} + type AgentAutocompleteCandidate = { pubkey?: string; displayName?: string | null; diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 64515ea74a..ad883df822 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,6 +16,7 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, + shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useInfiniteUserSearchQuery, @@ -209,6 +210,15 @@ export function useMentions( ), [relayAgentsQuery.data], ); + const directoryAgentPubkeys = React.useMemo( + () => + new Set( + (relayAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [relayAgentsQuery.data], + ); const sharedChannelIds = React.useMemo( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], @@ -269,9 +279,13 @@ export function useMentions( return; } if ( - candidate.isAgent && - !candidate.isMember && - !mentionableAgentPubkeys.has(pubkey) + shouldHideAgentFromMentions({ + isAgent: candidate.isAgent === true, + isMember: candidate.isMember === true, + pubkey, + mentionableAgentPubkeys, + directoryAgentPubkeys, + }) ) { return; } @@ -425,6 +439,7 @@ export function useMentions( userSearchResults, canSearchGlobalUsers, currentPubkey, + directoryAgentPubkeys, isArchivedDiscovery, managedAgentNamesByPubkey, managedAgentPersonaIds, From e51fb169f0799ef9bc3150fdc48f8258d2270c64 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Wed, 8 Jul 2026 09:08:53 -0500 Subject: [PATCH 2/2] Document directoryAgentPubkeys / mentionableAgentPubkeys shared-source coupling Address review feedback on #1611: add a comment on the member branch of shouldHideAgentFromMentions noting it treats directory presence (without mentionability) as an explicit not-invocable signal, which is only sound because both sets derive from the same relayAgentsQuery.data. Flags the premature-hide risk if a future change sources the directory set elsewhere. --- .../features/agents/lib/agentAutocompleteEligibility.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 0c7cb1cd50..51d840b9f2 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -76,6 +76,14 @@ export function shouldHideAgentFromMentions({ // Member (Option B): hide only when we have an explicit not-invocable // signal — a relay directory (kind:10100) entry that excludes us. // Unknown invocability (not in directory) => show. + // + // NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys` + // share the same source query (`relayAgentsQuery.data`), so directory + // presence without membership in `mentionableAgentPubkeys` is a real + // explicit-exclusion signal. If a future change sources the directory set + // from a different query, an agent that's directory-present but whose + // mentionability is still loading could be hidden prematurely — keep the + // two sets derived from the same query. return directoryAgentPubkeys.has(normalized); }