From c2eb361ce3858bc824693200ccb99c72f4294b4c Mon Sep 17 00:00:00 2001 From: JJ Garcia-Rovira Date: Fri, 24 Jul 2026 21:41:18 -0400 Subject: [PATCH] fix(desktop): show invocable relay-directory agents in mention/add gate [HIV-13] PR #2149 (a4dfead2) added `isAgentIdentityInManagedList`, which drops any candidate flagged `isAgent` whose pubkey is not in the viewer's LOCAL managed-agents list. That gate runs in mention autocomplete (useMentions) and the add-members picker (MembersSidebar). The gate is too narrow for box-run resident agents: NIP-OA-badged agents that run on a shared relay get `isAgent=true` from a valid owner-auth tag in their kind:0, and advertise a proper kind:10100 directory card with respond_to:"anyone" and shared channel_ids. They are legitimately invocable by the viewer, but they never appear in any single viewer's local managed list, so #2149 made them invisible to both mention and add. Widen the gate to admit an agent candidate when its normalized pubkey is in EITHER the locally-managed set OR the mentionable set (locally-managed union relay-directory agents shared with the viewer, as already computed by getMentionableAgentPubkeys and used by shouldHideAgentFromMentions). A bare agent badge with no directory relationship is still dropped, so #2149's dedup/ranking intent is preserved -- this only restores agents the viewer can actually invoke. - eligibility: isAgentIdentityInManagedList takes the mentionable set as a third arg and admits via either set. - useMentions: pass the mentionableAgentPubkeys it already computes. - MembersSidebar: build the same set (useChannelsQuery + getMentionableAgentPubkeys/getSharedChannelIds) and pass it. - tests: cover invocable-relay-agent admission and the neither-set drop; update existing call-site tests for the new arity. useMentions memos were tightened (folded the single-use sharedChannelIds memo, compacted personaNameByPubkey) to keep the file within the 1000-line size guard. --- .../lib/agentAutocompleteEligibility.test.mjs | 40 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 31 +++++++++++++- .../features/channels/ui/MembersSidebar.tsx | 21 +++++++++- .../src/features/messages/lib/useMentions.ts | 25 ++++++------ 4 files changed, 101 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..693bed07ca 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -138,11 +138,13 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); + const mentionableAgentPubkeys = new Set(); assert.equal( isAgentIdentityInManagedList( { isAgent: false, pubkey: PUB_B }, managedAgentPubkeys, + mentionableAgentPubkeys, ), true, ); @@ -150,6 +152,7 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent isAgentIdentityInManagedList( { isAgent: true, pubkey: PUB_A.toUpperCase() }, managedAgentPubkeys, + mentionableAgentPubkeys, ), true, ); @@ -157,11 +160,48 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent isAgentIdentityInManagedList( { isAgent: true, pubkey: PUB_B }, managedAgentPubkeys, + mentionableAgentPubkeys, ), false, ); }); +test("isAgentIdentityInManagedList: admits invocable relay agents via the mentionable set", () => { + const managedAgentPubkeys = new Set([PUB_A]); + // PUB_B is a box-run relay-directory agent: shared with the viewer (so it is + // invocable and lands in the mentionable set) but absent from the viewer's + // local managed list. + const mentionableAgentPubkeys = new Set([PUB_A, PUB_B]); + + // (a) isAgent, in the mentionable-but-not-managed set => admitted. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B.toUpperCase() }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + true, + ); + // (b) isAgent, in neither set => dropped. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_C }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + false, + ); + // (c) non-agent => unaffected, always admitted. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: false, pubkey: PUB_C }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + true, + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..f09deec95d 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,40 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +/** + * Gate for agent identities surfaced in mention autocomplete and the + * add-members picker. Non-agent candidates always pass. An agent candidate is + * admitted when its normalized pubkey is reachable by the viewer through + * EITHER of two paths: + * + * 1. `managedAgentPubkeys` — agents the viewer manages locally. This is the + * original #2149 gate: a bare kind:0 owner-auth badge (`isAgent === true`) + * must not, on its own, surface an agent the viewer has no relationship to. + * 2. `mentionableAgentPubkeys` — agents the viewer can actually invoke: the + * locally-managed set unioned with relay-directory (kind:10100) agents + * shared with the viewer (`respond_to: "anyone"` in a shared channel, or an + * allowlist that includes the viewer). See `getMentionableAgentPubkeys`. + * + * Path 2 exists for box-run resident agents: NIP-OA-badged agents that live on + * a shared relay and publish a proper directory card. They are never in any + * single viewer's local managed list, yet they are legitimately invocable and + * must stay visible to mention and add. Without this path #2149 drops them + * purely because they are flagged `isAgent` and absent from the local managed + * list. Admitting via either set preserves #2149's intent (a bare agent badge + * still is not enough) while restoring reachable directory agents. + */ export function isAgentIdentityInManagedList( candidate: { isAgent?: boolean; pubkey: string }, managedAgentPubkeys: ReadonlySet, + mentionableAgentPubkeys: ReadonlySet, ) { + if (candidate.isAgent !== true) { + return true; + } + const normalized = normalizePubkey(candidate.pubkey); return ( - candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + managedAgentPubkeys.has(normalized) || + mentionableAgentPubkeys.has(normalized) ); } diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 4fa3a575aa..3d00f3debe 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -4,9 +4,12 @@ import { Bot, UserRoundPlus, X } from "lucide-react"; import { useAddChannelMembersMutation, useChannelMembersQuery, + useChannelsQuery, } from "@/features/channels/hooks"; import { coalesceAgentAutocompleteCandidates, + getMentionableAgentPubkeys, + getSharedChannelIds, isAgentIdentityInManagedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; @@ -143,6 +146,7 @@ export function MembersSidebar({ >(() => new Set()); const identityQuery = useIdentityQuery(); const membersQuery = useChannelMembersQuery(channelId, open); + const channelsQuery = useChannelsQuery(); const addMembersMutation = useAddChannelMembersMutation(channelId); const changeRoleMutation = useMutation({ mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { @@ -260,6 +264,16 @@ export function MembersSidebar({ .filter((label): label is string => Boolean(label)), ); const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys()); + // Invocable set = locally-managed ∪ relay-directory agents shared with the + // viewer. Lets box-run resident agents (NIP-OA-badged, kind:10100 card, + // shared channel) pass the agent gate even though they are absent from the + // local managed list. Mirrors useMentions' mention-autocomplete gate. + const mentionableAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys, + relayAgents: relayAgentsQuery.data, + sharedChannelIds: getSharedChannelIds(channelsQuery.data), + }); const addCandidate = (candidate: AddMemberSearchCandidate) => { const pubkey = normalizePubkey(candidate.pubkey); @@ -270,7 +284,11 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + mentionableAgentPubkeys, + ) ) { return; } @@ -349,6 +367,7 @@ export function MembersSidebar({ }); }, [ canAddMembers, + channelsQuery.data, isArchivedDiscovery, currentPubkey, managedAgentsQuery.data, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..d36797e94a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -188,35 +188,28 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const sharedChannelIds = React.useMemo( - () => getSharedChannelIds(channelsQuery.data), - [channelsQuery.data], - ); const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ currentPubkey, managedAgentPubkeys, relayAgents: relayAgentsQuery.data, - sharedChannelIds, + sharedChannelIds: getSharedChannelIds(channelsQuery.data), }), [ currentPubkey, managedAgentPubkeys, relayAgentsQuery.data, - sharedChannelIds, + channelsQuery.data, ], ); const personaNameByPubkey = React.useMemo(() => { - const agents = managedAgentsQuery.data ?? []; const personas = personasQuery.data ?? []; const personaById = new Map(personas.map((p) => [p.id, p.displayName])); const lookup = new Map(); - for (const agent of agents) { - if (agent.personaId) { - const name = personaById.get(agent.personaId); - if (name) lookup.set(normalizePubkey(agent.pubkey), name); - } + for (const agent of managedAgentsQuery.data ?? []) { + const name = agent.personaId && personaById.get(agent.personaId); + if (name) lookup.set(normalizePubkey(agent.pubkey), name); } return lookup; }, [managedAgentsQuery.data, personasQuery.data]); @@ -246,7 +239,13 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if ( + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + mentionableAgentPubkeys, + ) + ) { return; } if (