diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..621c6d2b40 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -246,7 +246,16 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + // The managed-list guard only applies to NON-member identities: it + // exists to drop stale/ghost agent identities surfaced by discovery, + // but an agent owned by ANOTHER user is never in this install's + // managed list, and as a relay-confirmed channel member it must stay + // mentionable. Member-agent visibility is owned by + // shouldHideAgentFromMentions below (invocable / directory-exclusion). + if ( + candidate.isMember !== true && + !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + ) { return; } if ( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4cfc553df1..26fffd9eb5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -96,6 +96,16 @@ type MockManagedAgentRuntimeSeed = { lifecycle?: MockManagedAgentRuntimeRow["lifecycle"]; }; +/** A channel member with role "bot" that is NOT in the local managed-agent + * list nor the kind:10100 relay agent directory — models an agent owned by + * another community member, as seen from this install. */ +type MockChannelBotMemberSeed = { + pubkey: string; + name: string; + channelNames?: string[]; + channelIds?: string[]; +}; + type MockRelayAgentSeed = { pubkey: string; name: string; @@ -225,6 +235,9 @@ type E2eConfig = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + /** Channel members with role "bot" owned by another user: never in the + * managed list, never in the kind:10100 directory. */ + channelBotMembers?: MockChannelBotMemberSeed[]; /** Per agent+relay runtime rows for the pair-scoped lifecycle commands * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; @@ -2146,6 +2159,35 @@ function resetMockManagedAgents(config?: E2eConfig) { } } + // Foreign bot members: channel membership only — deliberately NOT added to + // mockManagedAgents, mockRelayAgents, or mockProfiles, so the client sees + // exactly what it sees for an agent owned by another community member. + for (const seed of config?.mock?.channelBotMembers ?? []) { + applyMockDisplayName(seed.pubkey, seed.name); + mockAgentPubkeys.add(seed.pubkey); + for (const channel of mockChannels) { + const isSeedChannel = + seed.channelIds?.includes(channel.id) || + seed.channelNames?.includes(channel.name); + if ( + !isSeedChannel || + channel.members.some((member) => member.pubkey === seed.pubkey) + ) { + continue; + } + + channel.members.push({ + pubkey: seed.pubkey, + role: "bot", + is_agent: true, + joined_at: new Date().toISOString(), + display_name: seed.name, + }); + syncMockChannel(channel); + touchMockChannel(channel); + } + } + syncMockRelayAgentsFromManagedAgents(); } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..31881753b9 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -24,6 +24,8 @@ const ALLOWLIST_RELAY_AGENT_PUBKEY = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; const DELAYED_RELAY_AGENT_PUBKEY = "9999999999999999999999999999999999999999999999999999999999999999"; +const FOREIGN_MEMBER_AGENT_PUBKEY = + "7777777777777777777777777777777777777777777777777777777777777777"; const CASEY_PROFILE_PUBKEY = "1111111111111111111111111111111111111111111111111111111111111111"; const PROFILE_ONLY_AGENT_PUBKEY = @@ -209,7 +211,9 @@ test("@ trigger prioritizes channel members before runnable personas and other m const dropdown = autocomplete(page); await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toHaveCount(0); + // alice is a channel member AND a directory agent with respond_to + // "anyone" — invocable, so she stays mentionable like any other member. + await expect(dropdown.getByText("alice")).toBeVisible(); await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); await expect(dropdown.getByText("charlie")).toBeVisible(); @@ -849,6 +853,62 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" await expect(autocomplete(page)).toHaveCount(0); }); +test("channel-member agents owned by another user stay mentionable", async ({ + page, +}) => { + // vera is a channel member with role "bot" but is NOT in this install's + // managed-agent list nor the kind:10100 relay directory — exactly what an + // agent deployed by another community member looks like from this client. + await installMockBridge(page, { + channelBotMembers: [ + { + pubkey: FOREIGN_MEMBER_AGENT_PUBKEY, + name: "vera", + channelNames: ["general"], + }, + ], + }); + 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("@vera"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("vera")).toBeVisible(); + await expect(dropdown.getByTestId("mention-agent-icon")).toBeVisible(); + + await input.press("Enter"); + await page.keyboard.type(" ping"); + await page.getByTestId("send-message").click(); + + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "vera" }); + await expect(mentionChip).toBeVisible(); + + // The mention must resolve to a real p-tag on the wire, not just render. + await expect + .poll(() => + page.evaluate(() => { + const events = ( + window as Window & { + __BUZZ_E2E_SIGNED_EVENTS__?: Array<{ + content: string; + tags: string[][]; + }>; + } + ).__BUZZ_E2E_SIGNED_EVENTS__; + return ( + events?.find((event) => event.content.includes("ping"))?.tags ?? [] + ); + }), + ) + .toContainEqual(["p", FOREIGN_MEMBER_AGENT_PUBKEY]); +}); + test("mentioning an in-channel stopped managed agent starts it before sending", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ca4d62ddd6..a78f020333 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -61,6 +61,16 @@ type MockManagedAgentSeed = { respondToAllowlist?: string[]; }; +/** A channel member with role "bot" that is NOT in the local managed-agent + * list nor the kind:10100 relay agent directory — models an agent owned by + * another community member, as seen from this install. */ +type MockChannelBotMemberSeed = { + pubkey: string; + name: string; + channelNames?: string[]; + channelIds?: string[]; +}; + type MockSearchProfileSeed = { pubkey: string; displayName: string | null; @@ -211,6 +221,9 @@ type MockBridgeOptions = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + /** Channel members with role "bot" owned by another user: never in the + * managed list, never in the kind:10100 directory. */ + channelBotMembers?: MockChannelBotMemberSeed[]; /** Per agent+relay runtime rows for pair-scoped lifecycle commands. */ managedAgentRuntimes?: Array<{ pubkey: string;