diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd681..48d5eae985c 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,7 +5,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentAutocompleteEligible, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -136,25 +136,32 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { +test("isAgentAutocompleteEligible: keeps people, channel members, and current managed agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); assert.equal( - isAgentIdentityInManagedList( + isAgentAutocompleteEligible( { isAgent: false, pubkey: PUB_B }, managedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentAutocompleteEligible( { isAgent: true, pubkey: PUB_A.toUpperCase() }, managedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentAutocompleteEligible( + { isAgent: true, isMember: true, pubkey: PUB_B }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentAutocompleteEligible( { isAgent: true, pubkey: PUB_B }, managedAgentPubkeys, ), @@ -306,3 +313,80 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +// ── Composed gate: relay-only agents must survive both checks ────────────── +// +// Regression for the case where a viewer who manages no agents locally could +// never mention an agent. `useMentions` runs `isAgentAutocompleteEligible` +// before `shouldHideAgentFromMentions`, so that first gate must receive +// `getMentionableAgentPubkeys(...)` (local managed ∪ relay agents shared with +// the viewer). The channel-member clause alone does not cover a relay agent +// that is invocable but not a member of the channel being typed in. + +test("relay agent allowlisting the viewer survives the composed mention gate", () => { + const managedAgentPubkeys = []; // viewer manages no agents locally + const relayAgents = [ + { + pubkey: PUB_C, + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: [], + }, + ]; + const mentionableAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys, + relayAgents, + sharedChannelIds: new Set(["general"]), + }); + const candidate = { isAgent: true, isMember: false, pubkey: PUB_C }; + + assert.equal( + isAgentAutocompleteEligible(candidate, mentionableAgentPubkeys), + true, + "relay agent that allowlists the viewer must pass the first gate", + ); + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: false, + pubkey: PUB_C, + mentionableAgentPubkeys, + directoryAgentPubkeys: new Set([PUB_C]), + }), + false, + "an invocable relay agent must not be hidden", + ); + + // The pre-fix behaviour, pinned so the regression cannot return quietly. + assert.equal( + isAgentAutocompleteEligible(candidate, new Set(managedAgentPubkeys)), + false, + "local-only set drops the relay agent — this is why the gate must not use it", + ); +}); + +test("relay agent that excludes the viewer stays hidden", () => { + const mentionableAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys: [], + relayAgents: [ + { + pubkey: PUB_C, + respondTo: "allowlist", + respondToAllowlist: [OTHER_OWNER_PUBKEY], + channelIds: [], + }, + ], + sharedChannelIds: new Set(["general"]), + }); + + assert.equal( + isAgentAutocompleteEligible( + { isAgent: true, isMember: false, pubkey: PUB_C }, + mentionableAgentPubkeys, + ), + false, + "widening the gate must not expose agents that exclude the viewer", + ); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4a..19b70145ed5 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,24 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( - candidate: { isAgent?: boolean; pubkey: string }, - managedAgentPubkeys: ReadonlySet, +/** + * First-pass autocomplete gate. Non-agents and channel members always pass; + * any other agent must appear in `allowedAgentPubkeys`. + * + * Callers decide what "allowed" means, and the choice is load-bearing. Mention + * autocomplete must pass `getMentionableAgentPubkeys(...)` (local managed ∪ + * relay agents shared with the viewer) — passing the locally-managed set alone + * drops every relay agent before `shouldHideAgentFromMentions` can admit it, + * so a viewer who manages no agents locally can mention none of them. + */ +export function isAgentAutocompleteEligible( + candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string }, + allowedAgentPubkeys: ReadonlySet, ) { return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + candidate.isMember === true || + allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) ); } diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a23..1d71e9afff3 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -9,7 +9,7 @@ import { import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, - isAgentIdentityInManagedList, + isAgentAutocompleteEligible, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -282,7 +282,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentAutocompleteEligible(candidate, managedAgentPubkeys) ) { return; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b753390..ace272d5db2 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,7 +16,7 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentAutocompleteEligible, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -246,7 +246,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentAutocompleteEligible(candidate, mentionableAgentPubkeys)) { return; } if ( @@ -420,7 +420,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members,