diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..5014484fd7 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, isAgentIdentityInManagedList, + relayAgentIsInvocableByUser, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -306,3 +307,64 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +test("relayAgentIsInvocableByUser: anyone responds regardless of channel overlap", () => { + assert.equal( + relayAgentIsInvocableByUser( + { respondTo: "anyone", respondToAllowlist: [] }, + CURRENT_PUBKEY, + ), + true, + ); +}); + +test("relayAgentIsInvocableByUser: allowlist honors membership", () => { + const agent = { respondTo: "allowlist", respondToAllowlist: [CURRENT_PUBKEY] }; + assert.equal(relayAgentIsInvocableByUser(agent, CURRENT_PUBKEY), true); + assert.equal(relayAgentIsInvocableByUser(agent, OTHER_OWNER_PUBKEY), false); +}); + +test("relayAgentIsInvocableByUser: owner-only and unknown modes are not invocable", () => { + assert.equal( + relayAgentIsInvocableByUser( + { respondTo: "owner-only", respondToAllowlist: [] }, + CURRENT_PUBKEY, + ), + false, + ); + assert.equal( + relayAgentIsInvocableByUser({ respondTo: null, respondToAllowlist: [] }, CURRENT_PUBKEY), + false, + ); +}); + +test("shouldHideAgentFromMentions: relay-shared agent shows without local managed entry", () => { + // Regression: a shared agent owned by another member (directory-eligible, + // respond_to=anyone, shared channel) must be mentionable even though it is + // not in this device's managed-agents list. + const sharedChannelIds = new Set(["chan-1"]); + const relayAgents = [ + { + pubkey: PUB_D, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["chan-1"], + }, + ]; + const mentionable = getMentionableAgentPubkeys({ + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys: [], + relayAgents, + sharedChannelIds, + }); + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: PUB_D, + mentionableAgentPubkeys: mentionable, + directoryAgentPubkeys: new Set([PUB_D]), + }), + false, + ); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..cf489ee106 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -9,9 +9,12 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { ); } -export function relayAgentIsSharedWithUser( - agent: Pick, - sharedChannelIds: ReadonlySet, +/// Whether the relay directory says this agent would respond to the current +/// user at all, ignoring channel overlap. Used where the user is about to +/// CREATE the overlap (e.g. adding the agent to a channel), so requiring an +/// existing shared channel would be circular. +export function relayAgentIsInvocableByUser( + agent: Pick, currentPubkey?: string | null, ) { const normalizedCurrentPubkey = currentPubkey @@ -24,6 +27,18 @@ export function relayAgentIsSharedWithUser( .includes(normalizedCurrentPubkey); } + return agent.respondTo === "anyone"; +} + +export function relayAgentIsSharedWithUser( + agent: Pick, + sharedChannelIds: ReadonlySet, + currentPubkey?: string | null, +) { + if (agent.respondTo === "allowlist") { + return relayAgentIsInvocableByUser(agent, currentPubkey); + } + return ( agent.respondTo === "anyone" && agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) @@ -54,13 +69,18 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +/// Non-agents always pass; agent identities pass when their pubkey is in the +/// provided allow-set. Callers decide the set: the local managed list alone is +/// NOT sufficient — relay-directory agents that would respond to this user +/// (see `relayAgentIsInvocableByUser`) must be included, otherwise shared +/// agents owned by other members are silently unreachable. export function isAgentIdentityInManagedList( candidate: { isAgent?: boolean; pubkey: string }, - managedAgentPubkeys: ReadonlySet, + allowedAgentPubkeys: ReadonlySet, ) { return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) ); } diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..16ed636011 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -10,6 +10,7 @@ import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, isAgentIdentityInManagedList, + relayAgentIsInvocableByUser, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -271,7 +272,15 @@ export function MembersSidebar({ .map((member) => member.displayName?.trim().toLowerCase()) .filter((label): label is string => Boolean(label)), ); - const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys()); + // Agents addable to this channel: locally managed ones plus relay-directory + // agents that would respond to this user. Channel overlap is deliberately + // not required — adding the agent to this channel is what creates it. + const addableAgentPubkeys = new Set(managedAgentsByPubkey.keys()); + for (const agent of relayAgentsQuery.data ?? []) { + if (relayAgentIsInvocableByUser(agent, currentPubkey)) { + addableAgentPubkeys.add(normalizePubkey(agent.pubkey)); + } + } const addCandidate = (candidate: AddMemberSearchCandidate) => { const pubkey = normalizePubkey(candidate.pubkey); @@ -282,7 +291,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInManagedList(candidate, addableAgentPubkeys) ) { return; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..54bc1982a6 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,7 +16,6 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -246,9 +245,12 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { - return; - } + // NOTE: no pre-emptive managed-list gate here. `shouldHideAgentFromMentions` + // is the single authority: it already shows invocable agents (local managed + // OR relay-directory-eligible via `mentionableAgentPubkeys`), hides + // non-member non-invocable ones, and applies the member/directory rules. + // An earlier managed-list-only gate made shared agents owned by other + // members unreachable from the composer. if ( shouldHideAgentFromMentions({ isAgent: candidate.isAgent === true,