diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index f04d722e74..4e02b7bd68 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,6 +5,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentIdentityInManagedList, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -135,6 +136,32 @@ 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]); + + assert.equal( + isAgentIdentityInManagedList( + { isAgent: false, pubkey: PUB_B }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_A.toUpperCase() }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B }, + managedAgentPubkeys, + ), + 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 51d840b9f2..e4afe7fea4 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,6 +54,16 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +export function isAgentIdentityInManagedList( + candidate: { isAgent?: boolean; pubkey: string }, + managedAgentPubkeys: ReadonlySet, +) { + return ( + candidate.isAgent !== true || + managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + ); +} + export function shouldHideAgentFromMentions({ isAgent, isMember, diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index f5fdf6f3f2..4fa3a575aa 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -4,12 +4,10 @@ import { Bot, UserRoundPlus, X } from "lucide-react"; import { useAddChannelMembersMutation, useChannelMembersQuery, - useChannelsQuery, } from "@/features/channels/hooks"; import { coalesceAgentAutocompleteCandidates, - getMentionableAgentPubkeys, - getSharedChannelIds, + isAgentIdentityInManagedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -146,7 +144,6 @@ export function MembersSidebar({ const identityQuery = useIdentityQuery(); const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); - const channelsQuery = useChannelsQuery({ enabled: open }); const changeRoleMutation = useMutation({ mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { if (!channelId) throw new Error("No channel selected."); @@ -262,15 +259,7 @@ export function MembersSidebar({ .map((member) => member.displayName?.trim().toLowerCase()) .filter((label): label is string => Boolean(label)), ); - const sharedChannelIds = getSharedChannelIds(channelsQuery.data); - const eligibleAgentPubkeys = getMentionableAgentPubkeys({ - currentPubkey, - managedAgentPubkeys: (managedAgentsQuery.data ?? []).map( - (agent) => agent.pubkey, - ), - relayAgents: relayAgentsQuery.data, - sharedChannelIds, - }); + const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys()); const addCandidate = (candidate: AddMemberSearchCandidate) => { const pubkey = normalizePubkey(candidate.pubkey); @@ -281,7 +270,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - (candidate.isAgent && !eligibleAgentPubkeys.has(pubkey)) + !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) ) { return; } @@ -320,10 +309,6 @@ export function MembersSidebar({ } for (const agent of relayAgentsQuery.data ?? []) { - if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) { - continue; - } - addCandidate({ pubkey: agent.pubkey, displayName: agent.name, @@ -365,7 +350,6 @@ export function MembersSidebar({ }, [ canAddMembers, isArchivedDiscovery, - channelsQuery.data, currentPubkey, managedAgentsQuery.data, memberPubkeys, @@ -377,8 +361,7 @@ export function MembersSidebar({ const isAddSearchLoading = userSearchQuery.isLoading || managedAgentsQuery.isLoading || - relayAgentsQuery.isLoading || - channelsQuery.isLoading; + relayAgentsQuery.isLoading; const handlePeopleSearchScroll = useUserSearchFetchMoreOnScroll( userSearchQuery, canAddMembers && normalizedDeferredSearchQuery.length > 0, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index fe0fca99d8..0c73b75339 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,6 +16,7 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentIdentityInManagedList, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -245,6 +246,9 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } + if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + return; + } if ( shouldHideAgentFromMentions({ isAgent: candidate.isAgent === true, @@ -256,7 +260,6 @@ export function useMentions( ) { return; } - const current = candidatesByPubkey.get(pubkey); if (!current) { candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); @@ -289,7 +292,6 @@ export function useMentions( isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, }); }; - for (const member of members ?? []) { const pubkey = normalizePubkey(member.pubkey); const linkedPersonaId = activePersonaById.has(pubkey) @@ -418,6 +420,7 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, + managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members, @@ -540,10 +543,7 @@ export function useMentions( mentionQuery, activePersonaIds, ) - .slice( - 0, - Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidatesWithTeams.length), - ) + .slice(0, MENTION_SUGGESTION_LIMIT) .map(({ candidate, label }) => mapMentionCandidateToSuggestion({ candidate, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 8f50a6ab1a..dce8dd2fa7 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -812,10 +812,17 @@ test("routes an agent mention from an existing DM to the expanded conversation", ); }); -test("routes a relay-agent mention from an existing DM to the expanded conversation", async ({ +test("routes a managed relay-agent mention from an existing DM to the expanded conversation", async ({ page, }) => { await installMockBridge(page, { + managedAgents: [ + { + pubkey: DM_RELAY_AGENT_PUBKEY, + name: "quinn", + status: "stopped", + }, + ], relayAgents: [ { pubkey: DM_RELAY_AGENT_PUBKEY, @@ -856,15 +863,13 @@ test("routes a relay-agent mention from an existing DM to the expanded conversat page.locator("[data-active='true'][data-channel-id]"), ).toHaveAttribute("data-channel-id", sentChannelId ?? ""); await expect(page.getByTestId("chat-title")).toContainText("alice"); - await expect(page.getByTestId("chat-title")).toContainText( - DM_RELAY_AGENT_PUBKEY.slice(0, 8), - ); + await expect(page.getByTestId("chat-title")).toContainText("quinn"); const sendCommands = (await readCommandPayloadLog(page)).slice( baselineCommands.length, ); expect(sendCommands.map((entry) => entry.command)).toContain("open_dm"); - expect(sendCommands.map((entry) => entry.command)).not.toContain( + expect(sendCommands.map((entry) => entry.command)).toContain( "start_managed_agent", ); expect(sendCommands.map((entry) => entry.command)).not.toContain( @@ -2554,7 +2559,18 @@ test("home inbox manage affordance opens management without leaving home", async await expect(page).not.toHaveURL(/#\/channels\//); }); -test("members sidebar can invite and remove members", async ({ page }) => { +test("members sidebar can invite and remove managed agents", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); await page.goto("/"); await openMembersSidebar(page, "general"); const initialMemberCount = await readMembersTriggerCount(page); @@ -2847,9 +2863,18 @@ test("members sidebar collapses same-persona managed agents", async ({ await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1); }); -test("private-channel members can add members and bots without admin", async ({ +test("private-channel members can add people and managed agents without admin", async ({ page, }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); await page.goto("/"); // secret-projects is a private (non-DM) channel where the current user is a // plain member, not owner/admin. They should still be able to add members diff --git a/desktop/tests/e2e/identity-archive-hide.spec.ts b/desktop/tests/e2e/identity-archive-hide.spec.ts index b943568abb..39410e2631 100644 --- a/desktop/tests/e2e/identity-archive-hide.spec.ts +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -166,38 +166,38 @@ test.describe("NIP-IA hide archived from discovery", () => { }); // Member-adder (ChannelMemberInviteCard) — the invite search excludes - // existing channel members, so we test in #agents where Alice is NOT a + // existing channel members, so we test in #agents where Bob is NOT a // member (only the mock identity + Charlie are): the ONLY thing that can - // drop her from results is the archive filter. The control run (nothing - // archived) proves she would otherwise appear — guarding a vacuous green. + // drop Bob from results is the archive filter. The control run (nothing + // archived) proves he would otherwise appear — guarding a vacuous green. test("member-adder: archived non-member is omitted from invite search", async ({ page, }) => { - await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); + await installMockBridge(page, { archivedIdentities: [BOB_PUBKEY] }); await page.goto("/"); await page.getByTestId("channel-agents").click(); await page.getByTestId("channel-members-trigger").click(); await expect(page.getByTestId("members-sidebar")).toBeVisible(); - await page.getByTestId("channel-management-search-users").fill("alice"); + await page.getByTestId("channel-management-search-users").fill("bob"); await expect( - page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`), + page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`), ).toHaveCount(0); }); test("member-adder: control — non-archived non-member IS in invite search", async ({ page, }) => { - // Same channel/query, nothing archived: Alice now appears. This is the - // companion that makes the test above non-vacuous (proves she was dropped + // Same channel/query, nothing archived: Bob now appears. This is the + // companion that makes the test above non-vacuous (proves he was dropped // by the archive filter, not by member-exclusion or a broken search). await installMockBridge(page, { archivedIdentities: [] }); await page.goto("/"); await page.getByTestId("channel-agents").click(); await page.getByTestId("channel-members-trigger").click(); await expect(page.getByTestId("members-sidebar")).toBeVisible(); - await page.getByTestId("channel-management-search-users").fill("alice"); + await page.getByTestId("channel-management-search-users").fill("bob"); await expect( - page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`), + page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`), ).toBeVisible(); }); }); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 8a982a6da2..694b5abef5 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -187,11 +187,18 @@ async function expectAgentProfileActionsHidden( ).toHaveCount(0); } -test("@ trigger prioritizes channel members before runnable personas and other agents", async ({ +test("@ trigger prioritizes channel members before runnable personas and other managed agents", async ({ page, }) => { await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -202,7 +209,7 @@ test("@ trigger prioritizes channel members before runnable personas and other a const dropdown = autocomplete(page); await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toBeVisible(); + await expect(dropdown.getByText("alice")).toHaveCount(0); await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); await expect(dropdown.getByText("charlie")).toBeVisible(); @@ -219,7 +226,6 @@ test("@ trigger prioritizes channel members before runnable personas and other a 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"), @@ -228,11 +234,9 @@ test("@ trigger prioritizes channel members before runnable personas and other a 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); }); @@ -425,7 +429,18 @@ test("defers agent mentions until DM members finish loading", async ({ await expect(threadPanel).toContainText("before members resolve"); }); -test("autocomplete filters suggestions as user types", async ({ page }) => { +test("autocomplete filters managed-agent suggestions as user types", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "alice", + status: "stopped", + }, + ], + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -462,7 +477,7 @@ test("autocomplete searches global non-member people from the first typed charac await expect(tessaRow.getByText("not in channel")).toBeVisible(); }); -test("mention autocomplete pages global people search beyond the first 50 results", async ({ +test("mention autocomplete caps global people search at 50 results", async ({ page, }) => { const searchProfiles = Array.from({ length: 55 }, (_, index) => ({ @@ -479,10 +494,8 @@ test("mention autocomplete pages global people search beyond the first 50 result const dropdown = autocomplete(page); await expect(dropdown.locator("button")).toHaveCount(50); - await dropdown.evaluate((node) => node.scrollTo(0, node.scrollHeight)); - - await expect(dropdown.locator("button")).toHaveCount(55); - await expect(dropdown.getByText("Alex 55")).toBeVisible(); + await expect(dropdown.getByText("Alex 50")).toBeVisible(); + await expect(dropdown.getByText("Alex 55")).toHaveCount(0); await expect(dropdown.getByText("not in channel").last()).toBeVisible(); const searchCalls = (await readCommandPayloadLog(page)).filter( @@ -493,6 +506,10 @@ test("mention autocomplete pages global people search beyond the first 50 result expect.objectContaining({ payload: expect.objectContaining({ cursor: null, limit: 50 }), }), + ]), + ); + expect(searchCalls).not.toEqual( + expect.arrayContaining([ expect.objectContaining({ payload: expect.objectContaining({ cursor: "2", limit: 50 }), }), @@ -521,9 +538,18 @@ test("selecting a person mention inserts @Name into input", async ({ await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/); }); -test("selecting an agent mention inserts @Name into input", async ({ +test("selecting a managed agent mention inserts @Name into input", async ({ page, }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "alice", + status: "stopped", + }, + ], + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -691,9 +717,18 @@ test("selecting a persona mention reuses an existing persona agent", async ({ await expect(mentionChip).toHaveText("Fizz"); }); -test("relay-profile agents with member roles use the agent composer style", async ({ +test("managed relay-profile agents with member roles use the agent composer style", async ({ page, }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); await page.goto("/"); await openChannelBrowser(page); @@ -759,10 +794,17 @@ test("own profile-only agents are hidden from channel mentions", async ({ await expect(autocomplete(page)).toHaveCount(0); }); -test("allowlisted relay agents are visible in channel mentions", async ({ +test("managed relay agents are visible in channel mentions regardless of relay policy", async ({ page, }) => { await installMockBridge(page, { + managedAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + status: "stopped", + }, + ], relayAgents: [ { pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, @@ -784,7 +826,7 @@ test("allowlisted relay agents are visible in channel mentions", async ({ await expect(dropdown.getByText("agent")).toBeVisible(); }); -test("non-allowlisted relay agents stay hidden from channel mentions", async ({ +test("relay-only agents stay hidden from channel mentions even when allowlisted", async ({ page, }) => { await installMockBridge(page, { @@ -793,7 +835,7 @@ test("non-allowlisted relay agents stay hidden from channel mentions", async ({ pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, name: "quinn", respondTo: "allowlist", - respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey], + respondToAllowlist: [MOCK_VIEWER_PUBKEY], }, ], }); @@ -1364,9 +1406,18 @@ test("system member-joined rows render the joined person as a plain profile name await expect(joinedPersonName).not.toHaveAttribute("data-mention"); }); -test("selecting a non-member agent from a DM inserts @Name into input", async ({ +test("selecting a managed non-member agent from a DM inserts @Name into input", async ({ page, }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); await page.goto("/"); await page.getByTestId("channel-bob-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); @@ -1450,9 +1501,18 @@ test("sent non-member person mention uses the normal mention style", async ({ await expect(mentionChip.locator("svg")).toHaveCount(0); }); -test("sent non-member agent mention uses the agent mention style", async ({ +test("sent managed non-member agent mention uses the agent mention style", async ({ page, }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); await page.goto("/"); await page.getByTestId("channel-bob-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 75b817a8a0..6b4dde6f38 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -143,7 +143,16 @@ test("focus and split preserve reading context and interaction ownership", async await page.addInitScript(() => { localStorage.setItem("buzz.channels.threadViewMode", "focus"); }); - await installMockBridge(page); + await installMockBridge(page, { + managedAgents: [ + { + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + name: "alice", + status: "stopped", + }, + ], + }); await page.goto("/"); const rootId = await seedLongThread(page); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index a834780288..31800ae7e5 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -110,7 +110,16 @@ test.describe("list virtualization", () => { test("03 — members search shows both sticky titles under content-visibility", async ({ page, }) => { - await installMockBridge(page); + await installMockBridge(page, { + managedAgents: [ + { + pubkey: + "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea", + name: "charlie", + status: "stopped", + }, + ], + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general");