diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..20c6b8f854 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, + isAgentIdentityMentionable, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -17,6 +18,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, { @@ -162,6 +164,52 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent ); }); +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({ @@ -214,6 +262,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 e4afe7fea4..e1fdc6c52e 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -64,6 +64,25 @@ export function isAgentIdentityInManagedList( ); } +/** + * 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 fbaf1f5274..f3727598c0 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -356,7 +356,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); }); @@ -377,7 +377,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 0c73b75339..306438d20d 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, + isAgentIdentityMentionable, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -246,7 +246,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentIdentityMentionable(candidate, mentionableAgentPubkeys)) { return; } if ( @@ -420,7 +420,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 5e31235a18..29bed6ef2c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -209,7 +209,10 @@ test("@ trigger prioritizes channel members before runnable personas and other m const dropdown = autocomplete(page); await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toHaveCount(0); + // 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(); await expect(dropdown.getByText("charlie")).toBeVisible(); @@ -227,6 +230,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m const suggestionText = await suggestions.allInnerTexts(); const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz")); const bobIndex = suggestionText.findIndex((text) => text.includes("bob")); + const aliceIndex = suggestionText.findIndex((text) => text.includes("alice")); const charlieIndex = suggestionText.findIndex((text) => text.includes("charlie"), ); @@ -235,9 +239,13 @@ test("@ trigger prioritizes channel members before runnable personas and other m ); expect(fizzIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); + expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(charlieIndex).toBeGreaterThanOrEqual(0); expect(outsiderIndex).toEqual(-1); 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); });