diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 0f911ad41bf..5d7f6a1b63b 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -7,6 +7,7 @@ import { getMentionableAgentPubkeys, getSharedChannelIds, isAgentIdentityInAllowedList, + isAgentIdentityMentionable, isAgentMentionChannelType, relayAgentCanRespondInChannel, relayAgentIsSharedWithUser, @@ -21,6 +22,7 @@ const PUB_A = "1".repeat(64); const PUB_B = "2".repeat(64); const PUB_C = "3".repeat(64); const PUB_D = "4".repeat(64); +const PUB_HEX = "ab".repeat(32); function coalesce(candidates, options = {}) { return coalesceAgentAutocompleteCandidates(candidates, { @@ -255,6 +257,52 @@ test("isAgentIdentityInAllowedList: keeps people and only explicitly allowed age ); }); +test("isAgentIdentityMentionable: keeps people, managed agents, and mentionable relay agents", () => { + // Mirrors useMentions: the mentionable set from getMentionableAgentPubkeys + // is managed agents (PUB_A, PUB_HEX) plus shared relay agents (PUB_B). + const mentionableAgentPubkeys = new Set([PUB_A, PUB_B, PUB_HEX]); + + assert.equal( + isAgentIdentityMentionable( + { isAgent: false, pubkey: PUB_C }, + mentionableAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_HEX.toUpperCase() }, + mentionableAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_B }, + mentionableAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_C }, + mentionableAgentPubkeys, + ), + false, + ); + // Member agent with no usable directory record: isMember does not bypass + // the gate — an agent identity outside the mentionable set is filtered + // here, before shouldHideAgentFromMentions' member branch can run + // (pre-existing behavior, deliberately pinned). + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, isMember: true, pubkey: PUB_D }, + mentionableAgentPubkeys, + ), + false, + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ @@ -307,6 +355,10 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo ); }); +// Note: in useMentions' addCandidate flow this member branch only runs for +// candidates that already passed isAgentIdentityMentionable, which requires +// mentionable-set membership — so this unit behavior is currently unreachable +// end-to-end there (see the pinned member-agent case above). test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => { assert.equal( shouldHideAgentFromMentions({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 3fb4e23c154..20bb187b626 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -92,6 +92,25 @@ export function isAgentIdentityInAllowedList( ); } +/** + * Managed-list membership alone cannot admit remote agents: managed agents + * are never minted from relay events (see apply_inbound_managed_agent), so a + * shared relay agent has no local record on this device. Mention candidacy + * therefore checks the mentionable set from getMentionableAgentPubkeys — + * locally managed agents plus directory-mentionable relay agents, the same + * eligibility `useNewMessageRecipients` already trusts. An agent identity + * outside that set stays hidden. + */ +export function isAgentIdentityMentionable( + candidate: { isAgent?: boolean; pubkey: string }, + mentionableAgentPubkeys: ReadonlySet, +) { + return ( + candidate.isAgent !== true || + mentionableAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + ); +} + export function shouldHideAgentFromMentions({ isAgent, isMember, diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index ef516f4b01c..c02644c0129 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -367,7 +367,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -388,7 +388,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index cd52b1bebf3..daf1a4ae49e 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -17,7 +17,7 @@ import { filterCachedAgentSuggestions, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInAllowedList, + isAgentIdentityMentionable, isAgentMentionChannelType, shouldHideAgentFromMentions, uniqueAutocompleteLabels, @@ -255,7 +255,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys)) { + if (!isAgentIdentityMentionable(candidate, mentionableAgentPubkeys)) { return; } if ( diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ed00e8c3556..cfa7bb63d18 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -250,6 +250,9 @@ test("@ trigger prioritizes channel members before runnable personas and other m const dropdown = autocomplete(page); await expect(dropdown).toBeVisible(); + // alice is a shared relay agent (directory respond_to: "anyone" with a + // shared channel) and a channel member: mentionable even though she is + // not locally managed. await expect(dropdown.getByText("alice")).toBeVisible(); await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); @@ -280,8 +283,10 @@ test("@ trigger prioritizes channel members before runnable personas and other m expect(bobIndex).toBeGreaterThanOrEqual(0); expect(charlieIndex).toBeGreaterThanOrEqual(0); expect(outsiderIndex).toEqual(-1); - expect(aliceIndex).toBeLessThan(fizzIndex); expect(bobIndex).toBeLessThan(fizzIndex); + // alice is a channel member, so she sorts in the member tier ahead of + // personas and non-member managed agents. + expect(aliceIndex).toBeLessThan(fizzIndex); expect(fizzIndex).toBeLessThan(charlieIndex); });