From 07aff29f075b39c780ef4c2c247c56cf8a0cc207 Mon Sep 17 00:00:00 2001 From: Matt Rice Date: Sat, 1 Aug 2026 07:19:55 -0700 Subject: [PATCH 1/5] fix(desktop): show shared relay agents in mention autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mention candidates were dropped by isAgentIdentityInManagedList before shouldHideAgentFromMentions ever ran, so remote relay agents — which can never enter the local managed list (apply_inbound_managed_agent is patch-only by design) — were unmentionable on desktop even when their kind:10100 directory records made them eligible. Mobile has no such filter and shows them. Admit candidates through the mentionable set (managed agents plus directory-eligible relay agents) instead — the same eligibility useNewMessageRecipients already trusts. Agents in neither set stay hidden, preserving the guard's anti-impersonation posture. isAgentIdentityInManagedList is unchanged for its remaining consumer (MembersSidebar add-member search). Signed-off-by: Matt Rice --- .../lib/agentAutocompleteEligibility.test.mjs | 36 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 17 +++++++++ .../src/features/messages/lib/useMentions.ts | 4 +-- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 0f911ad41b..08e7ea3b57 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, @@ -255,6 +256,41 @@ 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) plus shared relay agents (PUB_B). + const mentionableAgentPubkeys = new Set([PUB_A, PUB_B]); + + assert.equal( + isAgentIdentityMentionable( + { isAgent: false, pubkey: PUB_C }, + mentionableAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_A.toUpperCase() }, + mentionableAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_B }, + mentionableAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_C }, + mentionableAgentPubkeys, + ), + false, + ); +}); + 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 3fb4e23c15..f67f0b9c3d 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -92,6 +92,23 @@ 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/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index cd52b1bebf..daf1a4ae49 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 ( From 15a2d88f162d3d68e1df62ecf385677e75e4ced3 Mon Sep 17 00:00:00 2001 From: Matt Rice Date: Sat, 1 Aug 2026 07:27:26 -0700 Subject: [PATCH 2/5] fix(desktop): harden mention-eligibility tests from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: the case-normalization assertion used a digit-only fixture (toUpperCase() is identity on digits, so it could never fail) — replaced with a letter-bearing hex fixture; pinned the member-agent- without-directory-record path explicitly; converted the new export's comment to JSDoc per repo convention; noted that the shouldHideAgentFromMentions member branch is gated upstream. Signed-off-by: Matt Rice --- .../lib/agentAutocompleteEligibility.test.mjs | 22 ++++++++++++++++--- .../lib/agentAutocompleteEligibility.ts | 16 ++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 08e7ea3b57..5d7f6a1b63 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -22,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, { @@ -258,8 +259,8 @@ 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) plus shared relay agents (PUB_B). - const mentionableAgentPubkeys = new Set([PUB_A, PUB_B]); + // 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( @@ -270,7 +271,7 @@ test("isAgentIdentityMentionable: keeps people, managed agents, and mentionable ); assert.equal( isAgentIdentityMentionable( - { isAgent: true, pubkey: PUB_A.toUpperCase() }, + { isAgent: true, pubkey: PUB_HEX.toUpperCase() }, mentionableAgentPubkeys, ), true, @@ -289,6 +290,17 @@ test("isAgentIdentityMentionable: keeps people, managed agents, and mentionable ), 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", () => { @@ -343,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 f67f0b9c3d..20bb187b62 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -92,13 +92,15 @@ 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. +/** + * 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, From 93f95f9e4dbeef5f6e9394c4a17bf03a0272b182 Mon Sep 17 00:00:00 2001 From: Matt Rice Date: Sat, 1 Aug 2026 08:01:05 -0700 Subject: [PATCH 3/5] test(desktop): expect shared relay agents in mention autocomplete e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mention-priority smoke test asserted alice hidden — alice is the mock's shared relay agent (directory respond_to "anyone" with a shared channel, channel member, not locally managed), which is exactly the candidate class the fix admits. Update expectations to the fixed behavior and pin her member-tier ordering. Signed-off-by: Matt Rice --- desktop/tests/e2e/mentions.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ed00e8c355..a55c52b5a2 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(); @@ -269,6 +272,9 @@ test("@ trigger prioritizes channel members before runnable personas and other m const aliceIndex = suggestionText.findIndex((text) => text.includes("alice")); 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"), ); @@ -278,10 +284,14 @@ test("@ trigger prioritizes channel members before runnable personas and other m expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(fizzIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); + expect(aliceIndex).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); }); From e94c35045f21f5b4e15dea2d57f319991458d1ed Mon Sep 17 00:00:00 2001 From: Matt Rice Date: Sat, 1 Aug 2026 08:15:48 -0700 Subject: [PATCH 4/5] style(desktop): satisfy biome useTemplate in personaCatalogRelay.test.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main currently fails `biome check .` on these two string concatenations; recent main pushes were docs-only so the path-filtered desktop CI job never surfaced it. Included here so this PR's CI can run green — happy to split into its own PR if preferred. Signed-off-by: Matt Rice --- desktop/src/features/agents/lib/personaCatalogRelay.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index ef516f4b01..c02644c012 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); }); From 4f8c64371ff7c4b457c824bb7a30a0a2e5aebbeb Mon Sep 17 00:00:00 2001 From: Matt Rice Date: Sat, 1 Aug 2026 08:28:27 -0700 Subject: [PATCH 5/5] style(desktop): format mentions e2e per biome Signed-off-by: Matt Rice --- desktop/tests/e2e/mentions.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index a55c52b5a2..cfa7bb63d1 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -272,9 +272,6 @@ test("@ trigger prioritizes channel members before runnable personas and other m const aliceIndex = suggestionText.findIndex((text) => text.includes("alice")); 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"), ); @@ -284,10 +281,8 @@ test("@ trigger prioritizes channel members before runnable personas and other m expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(fizzIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); - expect(aliceIndex).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.