From 26ea485cb5b59306438abaf4c4080afd9d1712cc Mon Sep 17 00:00:00 2001 From: JDiz00 <174381550+JDiz00@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:57:01 -0500 Subject: [PATCH 1/3] fix(desktop): show eligible remote member agents in mentions Signed-off-by: JDiz00 <174381550+JDiz00@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 56 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 19 +++---- .../src/features/messages/lib/useMentions.ts | 16 ++++-- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 0f911ad41b..d45345b1ac 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -320,6 +320,62 @@ test("shouldHideAgentFromMentions: shows member agents with unknown invocability ); }); +test("shouldHideAgentFromMentions: hides unknown member agents while directories load", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set(), + directoryReady: false, + }), + true, + ); +}); + +test("shouldHideAgentFromMentions: shows mentionable member agents while directories load", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set([PUB_A]), + directoryAgentPubkeys: new Set(), + directoryReady: false, + }), + false, + ); +}); + +test("shouldHideAgentFromMentions: shows non-agent members while directories load", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: false, + isMember: true, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set([PUB_A]), + directoryReady: false, + }), + false, + ); +}); + +test("shouldHideAgentFromMentions: shows unknown member agents after empty directories settle", () => { + assert.equal( + shouldHideAgentFromMentions({ + isAgent: true, + isMember: true, + pubkey: PUB_A, + mentionableAgentPubkeys: new Set(), + directoryAgentPubkeys: new Set(), + directoryReady: true, + }), + false, + ); +}); + test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { const mixedCase = "Ab".repeat(32); const normalized = mixedCase.toLowerCase(); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 3fb4e23c15..ec80031b39 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -98,12 +98,14 @@ export function shouldHideAgentFromMentions({ pubkey, mentionableAgentPubkeys, directoryAgentPubkeys, + directoryReady = true, }: { isAgent: boolean; isMember: boolean; pubkey: string; mentionableAgentPubkeys: ReadonlySet; directoryAgentPubkeys: ReadonlySet; + directoryReady?: boolean; }) { if (!isAgent) return false; const normalized = normalizePubkey(pubkey); @@ -111,18 +113,11 @@ export function shouldHideAgentFromMentions({ if (mentionableAgentPubkeys.has(normalized)) return false; // Non-member, non-invocable => hide (preserves prior behavior). if (!isMember) return true; - // Member (Option B): hide only when we have an explicit not-invocable - // signal — a relay directory (kind:10100) entry that excludes us. - // Unknown invocability (not in directory) => show. - // - // NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys` - // share the same source query (`relayAgentsQuery.data`), so directory - // presence without membership in `mentionableAgentPubkeys` is a real - // explicit-exclusion signal. If a future change sources the directory set - // from a different query, an agent that's directory-present but whose - // mentionability is still loading could be hidden prematurely — keep the - // two sets derived from the same query. - return directoryAgentPubkeys.has(normalized); + // A member agent with no directory result is unknown while either directory + // is loading, so wait for the complete answer before showing it. Once both + // directories are settled, directory presence without mentionability is an + // explicit exclusion; an empty directory remains an unknown, shown member. + return !directoryReady || directoryAgentPubkeys.has(normalized); } export function isAgentMentionChannelType(type?: string | null) { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index b9737f1f39..f7a5ee444a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -120,10 +120,9 @@ export function useMentions( relayAgentsQuery.data !== undefined || !relayAgentsQuery.isLoading || relayAgentsQuery.error !== null; - const canSearchGlobalUsers = - canSearchGlobalPeople && - managedAgentDirectoryReady && - relayAgentDirectoryReady; + const agentDirectoriesReady = + managedAgentDirectoryReady && relayAgentDirectoryReady; + const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; const userSearchQuery = useInfiniteUserSearchQuery(mentionQuery ?? "", { allowEmpty: true, enabled: canSearchGlobalUsers && mentionQuery !== null, @@ -257,7 +256,12 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys)) { + // Channel members must reach the directory-aware policy below. A remote + // agent can be a real member before its relay directory entry is known. + if ( + candidate.isMember !== true && + !isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys) + ) { return; } if ( @@ -267,6 +271,7 @@ export function useMentions( pubkey, mentionableAgentPubkeys, directoryAgentPubkeys, + directoryReady: agentDirectoriesReady, }) ) { return; @@ -423,6 +428,7 @@ export function useMentions( }, [ activePersonaById, activePersonas, + agentDirectoriesReady, userSearchResults, canSearchGlobalUsers, currentPubkey, From 235aa2cf98ca436c30bce8e0da1645dbff1e70d9 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 12 Aug 2026 09:08:45 -0600 Subject: [PATCH 2/3] fix(desktop): fail closed for agent mentions Require affirmative relay-directory admission before exposing remote agents, apply the internal same-owner boundary, and filter selected agent identities again when emitting mention tags. Cover directory errors, recovery, outgoing tags, and revocation before send. Co-authored-by: Carl Signed-off-by: Wes --- .../lib/agentAutocompleteEligibility.test.mjs | 66 +++++++- .../lib/agentAutocompleteEligibility.ts | 136 ++++++++++++++-- .../src/features/messages/lib/useMentions.ts | 102 +++++++----- desktop/src/testing/e2eBridge.ts | 4 + desktop/tests/e2e/mentions.spec.ts | 149 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 6 files changed, 397 insertions(+), 62 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index d45345b1ac..62c85d478d 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -3,7 +3,9 @@ import test from "node:test"; import { coalesceAgentAutocompleteCandidates, + filterAdmittedMentionPubkeys, filterCachedAgentSuggestions, + getAgentMentionAdmission, getMentionableAgentPubkeys, getSharedChannelIds, isAgentIdentityInAllowedList, @@ -307,7 +309,7 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo ); }); -test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => { +test("shouldHideAgentFromMentions: hides member agents without an affirmative directory grant", () => { assert.equal( shouldHideAgentFromMentions({ isAgent: true, @@ -316,7 +318,7 @@ test("shouldHideAgentFromMentions: shows member agents with unknown invocability mentionableAgentPubkeys: new Set(), directoryAgentPubkeys: new Set(), }), - false, + true, ); }); @@ -334,7 +336,7 @@ test("shouldHideAgentFromMentions: hides unknown member agents while directories ); }); -test("shouldHideAgentFromMentions: shows mentionable member agents while directories load", () => { +test("shouldHideAgentFromMentions: hides mentionable member agents while directories load", () => { assert.equal( shouldHideAgentFromMentions({ isAgent: true, @@ -344,7 +346,7 @@ test("shouldHideAgentFromMentions: shows mentionable member agents while directo directoryAgentPubkeys: new Set(), directoryReady: false, }), - false, + true, ); }); @@ -362,7 +364,7 @@ test("shouldHideAgentFromMentions: shows non-agent members while directories loa ); }); -test("shouldHideAgentFromMentions: shows unknown member agents after empty directories settle", () => { +test("shouldHideAgentFromMentions: hides unknown member agents after empty directories settle", () => { assert.equal( shouldHideAgentFromMentions({ isAgent: true, @@ -372,7 +374,7 @@ test("shouldHideAgentFromMentions: shows unknown member agents after empty direc directoryAgentPubkeys: new Set(), directoryReady: true, }), - false, + true, ); }); @@ -392,6 +394,58 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { ); }); +test("getAgentMentionAdmission: owner-only requires current verified ownership", () => { + const common = { + isAgent: true, + isManagedAgent: false, + pubkey: PUB_A, + currentPubkey: CURRENT_PUBKEY, + mentionableAgentPubkeys: new Set([PUB_A]), + directoryReady: true, + ownerOnly: true, + }; + + assert.equal( + getAgentMentionAdmission({ ...common, ownerPubkey: CURRENT_PUBKEY }), + "allow", + ); + assert.equal( + getAgentMentionAdmission({ ...common, ownerPubkey: OTHER_OWNER_PUBKEY }), + "deny", + ); + assert.equal( + getAgentMentionAdmission({ ...common, ownerPubkey: null }), + "unknown", + ); +}); + +test("getAgentMentionAdmission: unresolved directory state stays unknown", () => { + assert.equal( + getAgentMentionAdmission({ + isAgent: true, + isManagedAgent: false, + pubkey: PUB_A, + currentPubkey: CURRENT_PUBKEY, + ownerPubkey: CURRENT_PUBKEY, + mentionableAgentPubkeys: new Set([PUB_A]), + directoryReady: false, + ownerOnly: false, + }), + "unknown", + ); +}); + +test("filterAdmittedMentionPubkeys: rechecks agent admission without dropping people", () => { + assert.deepEqual( + filterAdmittedMentionPubkeys( + [PUB_A, PUB_B, PUB_C], + new Set([PUB_A, PUB_B]), + new Set([PUB_B]), + ), + [PUB_B, PUB_C], + ); +}); + test("coalesceAgentAutocompleteCandidates: keeps agents with the same persona id distinct", () => { const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" }); const second = makeAgent({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index ec80031b39..738af1982e 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -92,32 +92,138 @@ export function isAgentIdentityInAllowedList( ); } +export type AgentMentionAdmission = "allow" | "deny" | "unknown"; + +export function getAgentMentionAdmission({ + isAgent, + isManagedAgent, + pubkey, + ownerPubkey, + currentPubkey, + mentionableAgentPubkeys, + directoryReady, + ownerOnly, +}: { + isAgent: boolean; + isManagedAgent: boolean; + pubkey: string; + ownerPubkey?: string | null; + currentPubkey?: string | null; + mentionableAgentPubkeys: ReadonlySet; + directoryReady: boolean; + ownerOnly: boolean | undefined; +}): AgentMentionAdmission { + if (!isAgent) return "allow"; + if (!directoryReady || ownerOnly === undefined) return "unknown"; + + const normalized = normalizePubkey(pubkey); + if (!mentionableAgentPubkeys.has(normalized)) return "deny"; + if (!ownerOnly || isManagedAgent) return "allow"; + if (!ownerPubkey || !currentPubkey) return "unknown"; + + return normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey) + ? "allow" + : "deny"; +} + export function shouldHideAgentFromMentions({ isAgent, - isMember, + isManagedAgent = false, pubkey, + ownerPubkey, + currentPubkey, mentionableAgentPubkeys, - directoryAgentPubkeys, directoryReady = true, + ownerOnly = false, }: { isAgent: boolean; - isMember: boolean; + isManagedAgent?: boolean; pubkey: string; + ownerPubkey?: string | null; + currentPubkey?: string | null; mentionableAgentPubkeys: ReadonlySet; - directoryAgentPubkeys: ReadonlySet; directoryReady?: boolean; + ownerOnly?: boolean; }) { - if (!isAgent) return false; - const normalized = normalizePubkey(pubkey); - // Invocable => always show. - if (mentionableAgentPubkeys.has(normalized)) return false; - // Non-member, non-invocable => hide (preserves prior behavior). - if (!isMember) return true; - // A member agent with no directory result is unknown while either directory - // is loading, so wait for the complete answer before showing it. Once both - // directories are settled, directory presence without mentionability is an - // explicit exclusion; an empty directory remains an unknown, shown member. - return !directoryReady || directoryAgentPubkeys.has(normalized); + return ( + getAgentMentionAdmission({ + isAgent, + isManagedAgent, + pubkey, + ownerPubkey, + currentPubkey, + mentionableAgentPubkeys, + directoryReady, + ownerOnly, + }) !== "allow" + ); +} + +export function getAgentIdentityPubkeys({ + managedAgentPubkeys, + relayAgents, + members, + profileIsAgent, +}: { + managedAgentPubkeys: ReadonlySet; + relayAgents: readonly { pubkey: string }[]; + members: readonly { + pubkey: string; + isAgent?: boolean; + role?: string | null; + }[]; + profileIsAgent: (pubkey: string) => boolean; +}) { + return new Set([ + ...managedAgentPubkeys, + ...relayAgents.map(({ pubkey }) => normalizePubkey(pubkey)), + ...members + .filter( + (member) => + member.isAgent === true || + member.role === "bot" || + profileIsAgent(normalizePubkey(member.pubkey)), + ) + .map(({ pubkey }) => normalizePubkey(pubkey)), + ]); +} + +export function getAdmittedAgentPubkeys( + candidates: readonly { pubkey?: string; isAgent?: boolean }[], +) { + return new Set( + candidates.flatMap((candidate) => + candidate.isAgent && candidate.pubkey + ? [normalizePubkey(candidate.pubkey)] + : [], + ), + ); +} + +export function rememberSelectedAgentPubkeys( + target: Set, + selected: readonly { pubkey?: string; isAgent?: boolean }[], + selectionIsAgent: boolean, +) { + for (const candidate of selected) { + if (candidate.pubkey && (selectionIsAgent || candidate.isAgent === true)) { + target.add(normalizePubkey(candidate.pubkey)); + } + } +} + +export function filterAdmittedMentionPubkeys( + pubkeys: readonly string[], + agentIdentityPubkeys: ReadonlySet, + admittedAgentPubkeys: ReadonlySet, +) { + return pubkeys.filter((pubkey) => { + const normalized = normalizePubkey(pubkey); + return ( + !agentIdentityPubkeys.has(normalized) || + admittedAgentPubkeys.has(normalized) + ); + }); } export function isAgentMentionChannelType(type?: string | null) { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index f7a5ee444a..d583b4d658 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -5,6 +5,7 @@ import { useRelayAgentsQuery, useTeamsQuery, } from "@/features/agents/hooks"; +import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { useChannelMembersQuery, useChannelsQuery, @@ -14,11 +15,14 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { coalesceAgentAutocompleteCandidates, coalesceAutocompleteCandidatesByKey, + filterAdmittedMentionPubkeys, filterCachedAgentSuggestions, + getAdmittedAgentPubkeys, + getAgentIdentityPubkeys, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInAllowedList, isAgentMentionChannelType, + rememberSelectedAgentPubkeys, shouldHideAgentFromMentions, uniqueAutocompleteLabels, } from "@/features/agents/lib/agentAutocompleteEligibility"; @@ -78,7 +82,6 @@ function appendUniqueName(current: string[], name: string): string[] { ? current : [...current, name]; } - export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -94,6 +97,7 @@ export function useMentions( const [selectedAgentMentionNames, setSelectedAgentMentionNames] = React.useState([]); const selectedAgentMentionNamesRef = React.useRef([]); + const selectedAgentMentionPubkeysRef = React.useRef>(new Set()); selectedAgentMentionNamesRef.current = selectedAgentMentionNames; const mentionMapRef = React.useRef>(new Map()); const personaMentionMapRef = React.useRef>(new Map()); @@ -112,16 +116,16 @@ export function useMentions( const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); + const agentAccessOwnerOnlyQuery = useAgentAccessOwnerOnlyQuery(); const managedAgentDirectoryReady = - managedAgentsQuery.data !== undefined || - !managedAgentsQuery.isLoading || - managedAgentsQuery.error !== null; + managedAgentsQuery.data !== undefined && managedAgentsQuery.error === null; const relayAgentDirectoryReady = - relayAgentsQuery.data !== undefined || - !relayAgentsQuery.isLoading || - relayAgentsQuery.error !== null; + relayAgentsQuery.data !== undefined && relayAgentsQuery.error === null; + const ownerPolicyReady = + agentAccessOwnerOnlyQuery.data !== undefined && + agentAccessOwnerOnlyQuery.error === null; const agentDirectoriesReady = - managedAgentDirectoryReady && relayAgentDirectoryReady; + managedAgentDirectoryReady && relayAgentDirectoryReady && ownerPolicyReady; const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; const userSearchQuery = useInfiniteUserSearchQuery(mentionQuery ?? "", { allowEmpty: true, @@ -182,15 +186,6 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const directoryAgentPubkeys = React.useMemo( - () => - new Set( - (relayAgentsQuery.data ?? []).map((agent) => - normalizePubkey(agent.pubkey), - ), - ), - [relayAgentsQuery.data], - ); const sharedChannelIds = React.useMemo( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], @@ -230,7 +225,10 @@ export function useMentions( } return lookup; }, [managedAgentsQuery.data, personasQuery.data]); - const knownAgentPubkeys = mentionableAgentPubkeys; + const knownAgentPubkeys = new Set([ + ...mentionableAgentPubkeys, + ...managedAgentPubkeys, + ]); const activePersonas = React.useMemo( () => (personasQuery.data ?? []).filter((persona) => persona.isActive), [personasQuery.data], @@ -248,30 +246,36 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); + const agentIdentityPubkeys = React.useMemo( + () => + getAgentIdentityPubkeys({ + managedAgentPubkeys, + relayAgents: relayAgentsQuery.data ?? [], + members: members ?? [], + profileIsAgent: (pubkey) => profiles?.[pubkey]?.isAgent === true, + }), + [managedAgentPubkeys, members, profiles, relayAgentsQuery.data], + ); const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); - const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => { const pubkey = normalizePubkey(candidate.pubkey); if (isArchivedDiscovery(pubkey)) { return; } - // Channel members must reach the directory-aware policy below. A remote - // agent can be a real member before its relay directory entry is known. - if ( - candidate.isMember !== true && - !isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys) - ) { - return; - } if ( shouldHideAgentFromMentions({ isAgent: candidate.isAgent === true, - isMember: candidate.isMember === true, + isManagedAgent: candidate.isManagedAgent === true, pubkey, + ownerPubkey: candidate.ownerPubkey, + currentPubkey, mentionableAgentPubkeys, - directoryAgentPubkeys, - directoryReady: agentDirectoriesReady, + directoryReady: + candidate.isManagedAgent === true + ? managedAgentDirectoryReady + : relayAgentDirectoryReady, + ownerOnly: agentAccessOwnerOnlyQuery.data, }) ) { return; @@ -281,7 +285,6 @@ export function useMentions( candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); return; } - candidatesByPubkey.set(pubkey, { ...current, avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null, @@ -346,7 +349,6 @@ export function useMentions( : null, }); } - for (const agent of relayAgentsQuery.data ?? []) { const pubkey = normalizePubkey(agent.pubkey); addCandidate({ @@ -361,7 +363,6 @@ export function useMentions( isAgent: true, }); } - for (const agent of managedAgentsQuery.data ?? []) { addCandidate({ kind: "identity", @@ -376,7 +377,6 @@ export function useMentions( ownerPubkey: currentPubkey, }); } - if (canSearchGlobalUsers) { for (const user of userSearchResults) { const pubkey = normalizePubkey(user.pubkey); @@ -428,12 +428,12 @@ export function useMentions( }, [ activePersonaById, activePersonas, - agentDirectoriesReady, + agentAccessOwnerOnlyQuery.data, userSearchResults, canSearchGlobalUsers, currentPubkey, - directoryAgentPubkeys, isArchivedDiscovery, + managedAgentDirectoryReady, managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, @@ -443,10 +443,16 @@ export function useMentions( mentionableAgentPubkeys, personaNameByPubkey, profiles, + relayAgentDirectoryReady, relayAgentNamesByPubkey, relayAgentsQuery.data, ]); + const admittedAgentPubkeys = React.useMemo( + () => getAdmittedAgentPubkeys(mentionCandidates), + [mentionCandidates], + ); + const mentionCandidatesWithTeams = React.useMemo( () => [ ...mentionCandidates, @@ -657,6 +663,11 @@ export function useMentions( (suggestion.pubkey ? knownAgentPubkeys.has(normalizePubkey(suggestion.pubkey)) : false); + rememberSelectedAgentPubkeys( + selectedAgentMentionPubkeysRef.current, + selectedMentions, + isAgentMention, + ); if (isAgentMention) { setSelectedAgentMentionNames((current) => { const known = new Set(current.map((name) => name.toLowerCase())); @@ -800,14 +811,23 @@ export function useMentions( ); const extractMentionPubkeysForCurrentMentions = React.useCallback( - (text: string): string[] => - extractMentionPubkeys({ + (text: string): string[] => { + const extracted = extractMentionPubkeys({ text, selectedMentions: mentionMapRef.current, selectedDisplayNames: personaMentionMapRef.current.keys(), memberCandidates: mentionCandidates, - }), - [mentionCandidates], + }); + return filterAdmittedMentionPubkeys( + extracted, + new Set([ + ...agentIdentityPubkeys, + ...selectedAgentMentionPubkeysRef.current, + ]), + admittedAgentPubkeys, + ); + }, + [admittedAgentPubkeys, agentIdentityPubkeys, mentionCandidates], ); const extractMentionPersonas = React.useCallback( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 46bda44ffb..602a7f1448 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -289,6 +289,8 @@ type E2eConfig = { personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; + /** Reject successive relay-agent directory reads, then resume. */ + relayAgentListErrors?: (string | null)[]; /** Native-like huddle state seeded from authoritative role-bearing membership. */ huddle?: MockHuddleSeed; agentListDelayMs?: number; @@ -7431,6 +7433,8 @@ async function handleListRelayAgents( config: E2eConfig | undefined, ): Promise { await delayAgentList(config); + const error = config?.mock?.relayAgentListErrors?.shift(); + if (error) throw new Error(error); syncMockRelayAgentsFromManagedAgents(); return mockRelayAgents.map(cloneRelayAgent); } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ed00e8c355..7d6e296b3c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -84,6 +84,16 @@ async function readOutgoingMentionPubkeys( ).__BUZZ_E2E_COMMAND_LOG__ ?? []; for (const entry of entries) { + if (entry.command === "sign_event") { + const unsignedEvent = entry.payload as + | { content?: string; tags?: string[][] } + | undefined; + if (unsignedEvent?.content !== expectedContent) continue; + return (unsignedEvent.tags ?? []) + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + } + if (entry.command !== "plugin:websocket|send") continue; const data = ( entry.payload as { message?: { data?: string } } | undefined @@ -995,6 +1005,145 @@ test("relay-only allowlisted agents are visible in channel mentions", async ({ await expect(dropdown.getByText("agent")).toBeVisible(); }); +test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgentListErrors: ["mock directory unavailable", null], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + await expect(autocomplete(page)).toHaveCount(0); + + await page.evaluate(async () => { + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); + }); + await expect(autocomplete(page).getByText("quinn")).toBeVisible(); +}); + +test("relay-only allowlisted agents emit a p tag when sent", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn hello"); + await page.getByTestId("send-message").click(); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); +}); + +test("selected relay agents revoked before send emit no p tag", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + + await page.evaluate(async () => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill( + "mock directory revoked", + ); + const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + invalidateQueries: (filters: { + queryKey: readonly unknown[]; + }) => Promise; + getQueryState: ( + queryKey: readonly unknown[], + ) => { status?: string } | undefined; + }; + await queryClient.invalidateQueries({ queryKey: ["relay-agents"] }); + if (queryClient.getQueryState(["relay-agents"])?.status !== "error") { + throw new Error( + "relay-agent directory refetch did not enter error state", + ); + } + }); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); +}); + +test("owner-only builds hide other-owned relay agents", async ({ page }) => { + await installMockBridge(page, { + ownerOnlyAccessBuild: true, + searchProfiles: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + displayName: "quinn", + ownerPubkey: TEST_IDENTITIES.outsider.pubkey, + isAgent: true, + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill("@quinn"); + + await expect(autocomplete(page)).toHaveCount(0); +}); + test("relay-only allowlisted agents stay hidden outside their channel", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 50f792447c..6426801de9 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -250,6 +250,8 @@ type MockBridgeOptions = { personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; + /** Reject successive relay-agent directory reads, then resume. */ + relayAgentListErrors?: (string | null)[]; /** Delay both managed and relay agent directory reads. */ agentListDelayMs?: number; createManagedAgentDelayMs?: number; From dfc79a2533aa57175c403d74d15888185c8e31bd Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 12 Aug 2026 09:45:20 -0600 Subject: [PATCH 3/3] fix(desktop): revalidate agent mentions before send Hide remote agents while directory policy is refetching and refresh both agent directories immediately before sending. Drop agent p tags and audience promotion when the fresh authorization is absent, revoked, or errors. Co-authored-by: Carl Signed-off-by: Wes --- .../messages/lib/agentMentionRevalidation.ts | 143 ++++++++++++++++++ .../messages/lib/mentionCandidates.ts | 25 ++- .../src/features/messages/lib/useMentions.ts | 56 +++---- .../messages/ui/useMentionSendFlow.ts | 24 +-- desktop/tests/e2e/mentions.spec.ts | 57 +++++++ 5 files changed, 264 insertions(+), 41 deletions(-) create mode 100644 desktop/src/features/messages/lib/agentMentionRevalidation.ts diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts new file mode 100644 index 0000000000..b6a8c1f93a --- /dev/null +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -0,0 +1,143 @@ +import { + filterAdmittedMentionPubkeys, + getAgentMentionAdmission, + getMentionableAgentPubkeys, + type AgentEligibilityScope, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import * as React from "react"; +import type { MentionCandidate } from "./mentionCandidates"; + +type DirectoryResult = { + data: T | undefined; + error: Error | null; +}; + +export async function revalidateAgentMentionPubkeys({ + pubkeys, + agentPubkeys, + ownerPubkeysByAgent, + currentPubkey, + eligibilityScope, + sharedChannelIds, + ownerOnly, + ownerPolicyError, + refetchManagedAgents, + refetchRelayAgents, +}: { + pubkeys: readonly string[]; + agentPubkeys: ReadonlySet; + ownerPubkeysByAgent: ReadonlyMap; + currentPubkey: string | null; + eligibilityScope: AgentEligibilityScope; + sharedChannelIds: ReadonlySet; + ownerOnly: boolean | undefined; + ownerPolicyError: Error | null; + refetchManagedAgents: () => Promise>; + refetchRelayAgents: () => Promise>; +}) { + if (!pubkeys.some((pubkey) => agentPubkeys.has(normalizePubkey(pubkey)))) { + return [...pubkeys]; + } + + const [managedResult, relayResult] = await Promise.all([ + refetchManagedAgents(), + refetchRelayAgents(), + ]); + if ( + managedResult.error !== null || + relayResult.error !== null || + managedResult.data === undefined || + relayResult.data === undefined || + ownerOnly === undefined || + ownerPolicyError !== null + ) { + return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set()); + } + + const managedPubkeys = new Set( + managedResult.data.map((agent) => normalizePubkey(agent.pubkey)), + ); + const mentionablePubkeys = getMentionableAgentPubkeys({ + currentPubkey, + eligibilityScope, + managedAgentPubkeys: managedPubkeys, + relayAgents: relayResult.data, + sharedChannelIds, + }); + const admittedPubkeys = new Set( + [...agentPubkeys].filter( + (pubkey) => + getAgentMentionAdmission({ + isAgent: true, + isManagedAgent: managedPubkeys.has(pubkey), + pubkey, + ownerPubkey: ownerPubkeysByAgent.get(pubkey), + currentPubkey, + mentionableAgentPubkeys: mentionablePubkeys, + directoryReady: true, + ownerOnly, + }) === "allow", + ), + ); + return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, admittedPubkeys); +} + +export function useAgentMentionRevalidation({ + agentPubkeys, + getSelectedAgentPubkeys, + candidates, + currentPubkey, + eligibilityScope, + sharedChannelIds, + ownerOnly, + ownerPolicyError, + refetchManagedAgents, + refetchRelayAgents, +}: { + agentPubkeys: ReadonlySet; + getSelectedAgentPubkeys: () => ReadonlySet; + candidates: readonly MentionCandidate[]; + currentPubkey: string | null; + eligibilityScope: AgentEligibilityScope; + sharedChannelIds: ReadonlySet; + ownerOnly: boolean | undefined; + ownerPolicyError: Error | null; + refetchManagedAgents: () => Promise>; + refetchRelayAgents: () => Promise>; +}) { + return React.useCallback( + (pubkeys: readonly string[]) => + revalidateAgentMentionPubkeys({ + pubkeys, + agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]), + ownerPubkeysByAgent: new Map( + candidates.flatMap((candidate) => + candidate.pubkey && candidate.isAgent + ? [[normalizePubkey(candidate.pubkey), candidate.ownerPubkey]] + : [], + ), + ), + currentPubkey, + eligibilityScope, + sharedChannelIds, + ownerOnly, + ownerPolicyError, + refetchManagedAgents, + refetchRelayAgents, + }), + [ + agentPubkeys, + candidates, + currentPubkey, + eligibilityScope, + getSelectedAgentPubkeys, + ownerOnly, + ownerPolicyError, + refetchManagedAgents, + refetchRelayAgents, + sharedChannelIds, + ], + ); +} diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..3ad358a0d6 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -1,7 +1,30 @@ import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; -import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types"; +import type { + AgentPersona, + AgentTeam, + ChannelRole, + UserSearchResult, +} from "@/shared/api/types"; import { truncatePubkey } from "@/shared/lib/pubkey"; +export function formatSearchUserDisplayName(user: UserSearchResult) { + return user.displayName?.trim() || user.nip05Handle?.trim() || null; +} + +export function formatSearchUserSecondaryLabel(user: UserSearchResult) { + const displayName = user.displayName?.trim(); + const nip05Handle = user.nip05Handle?.trim(); + return displayName && nip05Handle ? nip05Handle : null; +} + +export function appendUniqueName(current: string[], name: string): string[] { + return current.some( + (candidate) => candidate.toLowerCase() === name.toLowerCase(), + ) + ? current + : [...current, name]; +} + export type TeamMentionMember = { displayName: string; kind: "identity" | "persona"; diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index d583b4d658..5e64760acd 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -36,20 +36,23 @@ import type { AgentPersona, ChannelMember, ChannelType, - UserSearchResult, } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { flushMentionDebounce } from "./flushMentionDebounce"; +import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import { hasMention } from "./hasMention"; import { extractMentionPubkeys } from "./extractMentionPubkeys"; import { useDraftMentionRouting } from "./useDraftMentionRouting"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { + appendUniqueName, buildTeamMentionCandidates, + formatSearchUserDisplayName, + formatSearchUserSecondaryLabel, formatTeamMention, globalSearchIdentityKey, type MentionCandidate, @@ -64,24 +67,6 @@ export type PersonaMentionTarget = { type UseMentionsOptions = { channelType?: ChannelType | null; }; -function formatSearchUserDisplayName(user: UserSearchResult) { - return user.displayName?.trim() || user.nip05Handle?.trim() || null; -} -function formatSearchUserSecondaryLabel(user: UserSearchResult) { - const displayName = user.displayName?.trim(); - const nip05Handle = user.nip05Handle?.trim(); - if (displayName && nip05Handle) { - return nip05Handle; - } - return null; -} -function appendUniqueName(current: string[], name: string): string[] { - return current.some( - (candidate) => candidate.toLowerCase() === name.toLowerCase(), - ) - ? current - : [...current, name]; -} export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -118,12 +103,17 @@ export function useMentions( const teamsQuery = useTeamsQuery(); const agentAccessOwnerOnlyQuery = useAgentAccessOwnerOnlyQuery(); const managedAgentDirectoryReady = - managedAgentsQuery.data !== undefined && managedAgentsQuery.error === null; + managedAgentsQuery.data !== undefined && + managedAgentsQuery.error === null && + !managedAgentsQuery.isFetching; const relayAgentDirectoryReady = - relayAgentsQuery.data !== undefined && relayAgentsQuery.error === null; + relayAgentsQuery.data !== undefined && + relayAgentsQuery.error === null && + !relayAgentsQuery.isFetching; const ownerPolicyReady = agentAccessOwnerOnlyQuery.data !== undefined && - agentAccessOwnerOnlyQuery.error === null; + agentAccessOwnerOnlyQuery.error === null && + !agentAccessOwnerOnlyQuery.isFetching; const agentDirectoriesReady = managedAgentDirectoryReady && relayAgentDirectoryReady && ownerPolicyReady; const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; @@ -401,7 +391,6 @@ export function useMentions( }); } } - const personaCandidates: MentionCandidate[] = activePersonas .filter((persona) => !managedAgentPersonaIds.has(persona.id)) .map((persona) => ({ @@ -413,7 +402,6 @@ export function useMentions( isAgent: true, })) .filter((candidate) => candidate.displayName.trim().length > 0); - return coalesceAgentAutocompleteCandidates( coalesceAutocompleteCandidatesByKey( [...candidatesByPubkey.values(), ...personaCandidates], @@ -447,12 +435,10 @@ export function useMentions( relayAgentNamesByPubkey, relayAgentsQuery.data, ]); - const admittedAgentPubkeys = React.useMemo( () => getAdmittedAgentPubkeys(mentionCandidates), [mentionCandidates], ); - const mentionCandidatesWithTeams = React.useMemo( () => [ ...mentionCandidates, @@ -464,7 +450,6 @@ export function useMentions( ], [mentionCandidates, personasQuery.data, teamsQuery.data], ); - const ownerPubkeys = React.useMemo( () => [ ...new Set( @@ -478,7 +463,6 @@ export function useMentions( const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { enabled: ownerPubkeys.length > 0, }); - const searchableNames = React.useMemo( () => uniqueAutocompleteLabels(mentionCandidatesWithTeams), [mentionCandidatesWithTeams], @@ -830,6 +814,21 @@ export function useMentions( [admittedAgentPubkeys, agentIdentityPubkeys, mentionCandidates], ); + const revalidateMentionPubkeys = useAgentMentionRevalidation({ + agentPubkeys: agentIdentityPubkeys, + getSelectedAgentPubkeys: () => selectedAgentMentionPubkeysRef.current, + candidates: mentionCandidates, + currentPubkey, + eligibilityScope: mentionChannelId + ? { type: "channel", channelId: mentionChannelId } + : { type: "managed-only" }, + sharedChannelIds, + ownerOnly: agentAccessOwnerOnlyQuery.data, + ownerPolicyError: agentAccessOwnerOnlyQuery.error, + refetchManagedAgents: managedAgentsQuery.refetch, + refetchRelayAgents: relayAgentsQuery.refetch, + }); + const extractMentionPersonas = React.useCallback( (text: string): PersonaMentionTarget[] => { const targets: PersonaMentionTarget[] = []; @@ -972,6 +971,7 @@ export function useMentions( clearMentions, extractMentionPersonas, extractMentionPubkeys: extractMentionPubkeysForCurrentMentions, + revalidateMentionPubkeys, getDraftMentionRefs, getMentionDisplayName, handleMentionKeyDown, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index cc51c73345..bfbc841828 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -240,7 +240,6 @@ export function useMentionSendFlow({ startAgentMutation, ], ); - const createMentionedPersonaAgents = React.useCallback( async (trimmed: string, capturedChannelId: string) => { const personaMentions = mentions.extractMentionPersonas(trimmed); @@ -251,7 +250,6 @@ export function useMentionSendFlow({ pubkeys: [] as string[], }; } - const runtimes = await getAvailableRuntimes(); const defaultRuntime = runtimes[0] ?? null; const errors: string[] = []; @@ -260,13 +258,11 @@ export function useMentionSendFlow({ const seenPersonaIds = new Set(); const shouldProvisionForDm = channelType === "dm" && Boolean(onPrepareSendChannel); - for (const { displayName, persona } of personaMentions) { if (seenPersonaIds.has(persona.id)) { continue; } seenPersonaIds.add(persona.id); - const { runtime } = resolvePersonaRuntime( persona.runtime, runtimes, @@ -276,7 +272,6 @@ export function useMentionSendFlow({ errors.push(`${displayName}: No agent runtime available.`); continue; } - try { const input: CreateChannelManagedAgentInput & { channelId: string; @@ -309,7 +304,6 @@ export function useMentionSendFlow({ ); } } - return { agents, errors, @@ -560,23 +554,28 @@ export function useMentionSendFlow({ outgoingTags ?? [], ); if (signal?.aborted) return; + const revalidatedMentionPubkeys = + await mentions.revalidateMentionPubkeys(mentionPubkeys); + if (signal?.aborted) return; + const revalidatedExplicitAgentPubkeys = + filterEffectiveExplicitAgentPubkeys( + draft.explicitAgentPubkeys, + revalidatedMentionPubkeys, + ); await send( finalContent, - mentionPubkeys, + revalidatedMentionPubkeys, finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); if (signal?.aborted) return; - if (effectiveExplicitAgentPubkeys.length > 0) { - // Promote only explicitly authored agents that remained effective - // for this successful send. "Send without inviting" removes its - // excluded recipients here as well as from event routing. + if (revalidatedExplicitAgentPubkeys.length > 0) { onSuccessfulExplicitAgentAudience?.({ channelId: sendChannelId ?? draft.capturedChannelId ?? "", expectedGeneration: draft.audienceGeneration, expectedRevision: draft.audienceRevision, - explicitAgentPubkeys: effectiveExplicitAgentPubkeys, + explicitAgentPubkeys: revalidatedExplicitAgentPubkeys, }); } if (draft.sentDraftKey) { @@ -647,6 +646,7 @@ export function useMentionSendFlow({ ensureManagedAgentMentionsReady, getManagedAgentsByPubkey, mentions.isAgentPubkey, + mentions.revalidateMentionPubkeys, onPrepareSendChannel, onSendRef, onSuccessfulExplicitAgentAudience, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 7d6e296b3c..9d2f36f0d5 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1032,6 +1032,16 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch", }); }); await expect(autocomplete(page).getByText("quinn")).toBeVisible(); + + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000; + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); + }); + await expect(autocomplete(page).getByText("quinn")).toHaveCount(0); + await expect(autocomplete(page).getByText("quinn")).toBeVisible(); }); test("relay-only allowlisted agents emit a p tag when sent", async ({ @@ -1111,6 +1121,53 @@ test("selected relay agents revoked before send emit no p tag", async ({ }); await page.getByTestId("send-message").click(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toBeNull(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); +}); + +test("selected relay agents revoked during send emit no p tag", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000; + }); + await page.getByTestId("send-message").click(); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill( + "mock directory revoked mid-send", + ); + }); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toBeNull(); await expect .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);