diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..a9022ae48f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -6,6 +6,7 @@ import { getMentionableAgentPubkeys, getSharedChannelIds, isAgentIdentityInManagedList, + isAgentMentionReachable, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -162,6 +163,39 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent ); }); +test("isAgentMentionReachable: keeps remote channel agents without restoring stale non-members", () => { + const managedAgentPubkeys = new Set([PUB_A]); + + assert.equal( + isAgentMentionReachable( + { isAgent: false, isMember: false, pubkey: PUB_D }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentMentionReachable( + { isAgent: true, isMember: false, pubkey: PUB_A }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentMentionReachable( + { isAgent: true, isMember: true, pubkey: PUB_B }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentMentionReachable( + { isAgent: true, isMember: false, pubkey: PUB_C }, + 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 e4afe7fea4..b1f2c96334 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -64,6 +64,23 @@ export function isAgentIdentityInManagedList( ); } +/** + * Keep agent identities that this client can actually address. + * + * Channel members must reach the policy-aware gate below even when their + * process is managed on another machine. Non-members stay limited to agents + * managed by this client. + */ +export function isAgentMentionReachable( + candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string }, + managedAgentPubkeys: ReadonlySet, +) { + if (candidate.isAgent !== true) return true; + + const normalized = normalizePubkey(candidate.pubkey); + return candidate.isMember === true || managedAgentPubkeys.has(normalized); +} + export function shouldHideAgentFromMentions({ isAgent, isMember, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..9fa370faf0 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, + isAgentMentionReachable, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -246,7 +246,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentMentionReachable(candidate, managedAgentPubkeys)) { return; } if ( diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..c02d4aa6bb 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -187,7 +187,7 @@ async function expectAgentProfileActionsHidden( ).toHaveCount(0); } -test("@ trigger prioritizes channel members before runnable personas and other managed agents", async ({ +test("@ trigger prioritizes remote channel agents before runnable personas and other managed agents", async ({ page, }) => { await installMockBridge(page, { @@ -209,7 +209,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 +226,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 +235,59 @@ 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("mentioning a remote channel agent routes its pubkey without starting it locally", 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"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("alice")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" can you help?"); + + const baselineStartCount = commandCount( + await readCommandLog(page), + "start_managed_agent", + ); + await page.getByTestId("send-message").click(); + + 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("can you help?")) + ?.tags ?? [] + ); + }), + ) + .toContainEqual(["p", TEST_IDENTITIES.alice.pubkey]); + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount, + ); +}); + test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({ page, }) => {