diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..8f0eee3cec 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,7 +5,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInAllowedList, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -136,27 +136,27 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { - const managedAgentPubkeys = new Set([PUB_A]); +test("isAgentIdentityInAllowedList: keeps people and only listed agent identities", () => { + const allowedAgentPubkeys = new Set([PUB_A]); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInAllowedList( { isAgent: false, pubkey: PUB_B }, - managedAgentPubkeys, + allowedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInAllowedList( { isAgent: true, pubkey: PUB_A.toUpperCase() }, - managedAgentPubkeys, + allowedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInAllowedList( { isAgent: true, pubkey: PUB_B }, - managedAgentPubkeys, + allowedAgentPubkeys, ), false, ); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..44db1db6b1 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,13 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( +export function isAgentIdentityInAllowedList( 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..81a8caca82 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -9,7 +9,7 @@ import { import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, - isAgentIdentityInManagedList, + isAgentIdentityInAllowedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -282,7 +282,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInAllowedList(candidate, managedAgentPubkeys) ) { return; } diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 79dab559c4..625dc17360 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -36,6 +36,7 @@ import { useCompactComposerInteractions } from "./useCompactComposerInteractions export function ForumComposer({ channelId = null, + channelType, members, className, placeholder, @@ -69,7 +70,7 @@ export function ForumComposer({ if (compact) setIsCompactExpanded(true); }, [compact]); - const mentions = useMentions(channelId, members, profiles); + const mentions = useMentions(channelId, members, profiles, { channelType }); const channelLinks = useChannelLinks(); const media = useMediaUpload(); const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } = diff --git a/desktop/src/features/forum/ui/ForumComposer.types.ts b/desktop/src/features/forum/ui/ForumComposer.types.ts index a20e750186..bd3d9d2ef7 100644 --- a/desktop/src/features/forum/ui/ForumComposer.types.ts +++ b/desktop/src/features/forum/ui/ForumComposer.types.ts @@ -1,10 +1,12 @@ import type * as React from "react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { ChannelMember } from "@/shared/api/types"; +import type { ChannelMember, ChannelType } from "@/shared/api/types"; export type ForumComposerProps = { channelId?: string | null; + /** Known channel type for channel-backed composers; omitted uses fail closed. */ + channelType?: ChannelType | null; /** Override mention source when no channel is available (e.g. Pulse). */ members?: ChannelMember[]; className?: string; diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index c6f1bfa6c1..0e884dd247 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -293,6 +293,7 @@ export function ForumThreadPanel({
setIsComposerOpen(false)} onSubmit={async (content, mentionPubkeys, mediaTags) => { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..a93e15b759 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, + isAgentIdentityInAllowedList, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -93,7 +93,6 @@ export function useMentions( const mentionMapRef = React.useRef>(new Map()); const personaMentionMapRef = React.useRef>(new Map()); const previousSuggestionsRef = React.useRef([]); - void options?.channelType; const mentionSearchQuery = mentionQuery?.trim() ?? ""; const canSearchGlobalPeople = mentionSearchQuery.length > 0; const identityQuery = useIdentityQuery(); @@ -238,15 +237,18 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); + const allowedAgentPubkeys = + options?.channelType === "stream" || options?.channelType === "forum" + ? mentionableAgentPubkeys + : managedAgentPubkeys; const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); - const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => { const pubkey = normalizePubkey(candidate.pubkey); if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentIdentityInAllowedList(candidate, allowedAgentPubkeys)) { return; } if ( @@ -265,7 +267,6 @@ export function useMentions( candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); return; } - candidatesByPubkey.set(pubkey, { ...current, avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null, @@ -330,7 +331,6 @@ export function useMentions( : null, }); } - for (const agent of relayAgentsQuery.data ?? []) { const pubkey = normalizePubkey(agent.pubkey); addCandidate({ @@ -412,6 +412,7 @@ export function useMentions( }, [ activePersonaById, activePersonas, + allowedAgentPubkeys, userSearchResults, canSearchGlobalUsers, currentPubkey, @@ -420,7 +421,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..983d5bef9e 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -7,6 +7,7 @@ import { } from "../helpers/bridge"; const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -67,6 +68,46 @@ async function readCommandPayloadLog(page: import("@playwright/test").Page) { }); } +async function readOutgoingMentionPubkeys( + page: import("@playwright/test").Page, + content: string, +) { + return page.evaluate((expectedContent) => { + const entries = + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: unknown; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? []; + + for (const entry of entries) { + if (entry.command !== "plugin:websocket|send") continue; + const data = ( + entry.payload as { message?: { data?: string } } | undefined + )?.message?.data; + if (!data) continue; + + try { + const frame = JSON.parse(data) as [ + string, + { content?: string; tags?: string[][] }, + ]; + if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) { + continue; + } + return (frame[1].tags ?? []) + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + } catch {} + } + + return null; + }, content); +} + function commandCount(commands: string[], command: string) { return commands.filter((entry) => entry === command).length; } @@ -209,7 +250,7 @@ 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); + await expect(dropdown.getByText("alice")).toBeVisible(); await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); await expect(dropdown.getByText("charlie")).toBeVisible(); @@ -226,6 +267,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m const suggestions = dropdown.locator("button"); const suggestionText = await suggestions.allInnerTexts(); const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz")); + const aliceIndex = suggestionText.findIndex((text) => text.includes("alice")); const bobIndex = suggestionText.findIndex((text) => text.includes("bob")); const charlieIndex = suggestionText.findIndex((text) => text.includes("charlie"), @@ -234,13 +276,41 @@ test("@ trigger prioritizes channel members before runnable personas and other m text.includes("outsider"), ); expect(fizzIndex).toBeGreaterThanOrEqual(0); + expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); expect(charlieIndex).toBeGreaterThanOrEqual(0); expect(outsiderIndex).toEqual(-1); + expect(aliceIndex).toBeLessThan(fizzIndex); expect(bobIndex).toBeLessThan(fizzIndex); expect(fizzIndex).toBeLessThan(charlieIndex); }); +test("relay-only shared agents emit an outbound mention tag when selected", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Ask @alice"); + + const dropdown = autocomplete(page); + const aliceRow = dropdown.locator("button", { hasText: "alice" }); + await expect(aliceRow).toBeVisible(); + await expect(aliceRow.getByTestId("mention-agent-icon")).toBeVisible(); + await aliceRow.click(); + await page.keyboard.type("please reply"); + + const content = "Ask @alice please reply"; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toContain(TEST_IDENTITIES.alice.pubkey); +}); + test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({ page, }) => { @@ -429,6 +499,82 @@ test("defers agent mentions until DM members finish loading", async ({ await expect(threadPanel).toContainText("before members resolve"); }); +test("relay-only shared agents stay hidden from DM mentions", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + + const input = page.getByTestId("message-input"); + await input.fill("@"); + + await expect( + autocomplete(page).getByTestId(`mention-suggestion-${MOCK_VIEWER_PUBKEY}`), + ).toBeVisible(); + await expect( + autocomplete(page).getByTestId( + `mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`, + ), + ).toHaveCount(0); +}); + +test("relay-only shared agents stay hidden while channel type is unresolved", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@alice"); + await expect( + autocomplete(page).getByTestId( + `mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`, + ), + ).toBeVisible(); + await input.fill(""); + + await page.evaluate(async (channelId) => { + const bridge = window as Window & { + __BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise; + __BUZZ_E2E_MUTATE_CHANNEL__?: (opts: { + channelId: string; + channelType: null; + }) => void; + }; + bridge.__BUZZ_E2E_MUTATE_CHANNEL__?.({ channelId, channelType: null }); + await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); + }, GENERAL_CHANNEL_ID); + + await input.fill("@"); + + await expect( + autocomplete(page).getByTestId(`mention-suggestion-${MOCK_VIEWER_PUBKEY}`), + ).toBeVisible(); + await expect( + autocomplete(page).getByTestId( + `mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`, + ), + ).toHaveCount(0); +}); + +test("relay-only shared agents appear in forum mentions", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-watercooler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await page.getByRole("button", { name: "Start a new post..." }).click(); + + const input = page.getByTestId("message-input"); + await input.fill("@alice"); + + const aliceRow = page + .getByTestId("mention-autocomplete") + .getByTestId(`mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`); + await expect(aliceRow).toBeVisible(); + await expect(aliceRow.getByTestId("mention-agent-icon")).toBeVisible(); +}); + test("autocomplete filters managed-agent suggestions as user types", async ({ page, }) => { @@ -819,14 +965,24 @@ test("managed relay agents are visible in channel mentions regardless of relay p await expect(page.getByTestId("chat-title")).toHaveText("general"); const input = page.getByTestId("message-input"); - await input.fill("@quinn"); + await input.fill("Ask @quinn"); const dropdown = autocomplete(page); await expect(dropdown.getByText("quinn")).toBeVisible(); await expect(dropdown.getByText("agent")).toBeVisible(); + await dropdown.getByText("quinn").click(); + await page.keyboard.type("please reply"); + + const content = "Ask @quinn please reply"; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); }); -test("relay-only agents stay hidden from channel mentions even when allowlisted", async ({ +test("relay-only agents are visible in channel mentions when allowlisted", async ({ page, }) => { await installMockBridge(page, { @@ -846,7 +1002,9 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" const input = page.getByTestId("message-input"); await input.fill("@quinn"); - await expect(autocomplete(page)).toHaveCount(0); + const dropdown = autocomplete(page); + await expect(dropdown.getByText("quinn")).toBeVisible(); + await expect(dropdown.getByText("agent")).toBeVisible(); }); test("mentioning an in-channel stopped managed agent starts it before sending", async ({