Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
42 changes: 42 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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();
}

Expand Down
62 changes: 61 additions & 1 deletion desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
}) => {
Expand Down
13 changes: 13 additions & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down