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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
isAgentIdentityMentionable,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
} from "./agentAutocompleteEligibility.ts";
Expand Down Expand Up @@ -162,6 +163,32 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent
);
});

test("isAgentIdentityMentionable: admits in-channel bots owned elsewhere", () => {
const managedAgentPubkeys = new Set([PUB_A]);

assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, isMember: true, pubkey: PUB_B, role: "bot" },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, isMember: true, pubkey: PUB_B, role: "member" },
managedAgentPubkeys,
),
false,
);
assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
),
false,
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
20 changes: 20 additions & 0 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ export function isAgentIdentityInManagedList(
);
}

/**
* Mention autocomplete also admits bot-role channel members owned elsewhere.
* Their runtime remains the authority for the respond-to policy; this gate
* only allows the later relay-policy check to evaluate them.
*/
export function isAgentIdentityMentionable(
candidate: {
isAgent?: boolean;
isMember?: boolean;
pubkey: string;
role?: string | null;
},
managedAgentPubkeys: ReadonlySet<string>,
) {
return (
isAgentIdentityInManagedList(candidate, managedAgentPubkeys) ||
(candidate.isMember === true && candidate.role === "bot")
);
}

export function shouldHideAgentFromMentions({
isAgent,
isMember,
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
coalesceAutocompleteCandidatesByKey,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
isAgentIdentityMentionable,
shouldHideAgentFromMentions,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import {
Expand Down Expand Up @@ -246,7 +246,7 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
if (!isAgentIdentityMentionable(candidate, managedAgentPubkeys)) {
return;
}
if (
Expand Down
33 changes: 33 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type MockRelayAgentSeed = {
respondToAllowlist?: string[];
channelNames?: string[];
channelIds?: string[];
memberChannelNames?: string[];
status?: PresenceStatus;
};

Expand Down Expand Up @@ -1962,6 +1963,38 @@ function resetMockRelayAgents(config?: E2eConfig) {
respond_to: seed.respondTo ?? "owner-only",
respond_to_allowlist: seed.respondToAllowlist ?? [],
});

if (!seed.memberChannelNames?.length) {
continue;
}
applyMockDisplayName(seed.pubkey, seed.name);
mockAgentPubkeys.add(seed.pubkey);
mockProfiles.set(seed.pubkey, {
pubkey: seed.pubkey,
display_name: seed.name,
avatar_url: null,
about: null,
nip05_handle: null,
owner_pubkey: null,
is_agent: true,
has_profile_event: true,
});
for (const channel of mockChannels) {
if (
!seed.memberChannelNames?.includes(channel.name) ||
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);
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,32 @@ test("managed relay agents are visible in channel mentions regardless of relay p
await expect(dropdown.getByText("agent")).toBeVisible();
});

test("shared in-channel bot agents are visible to non-owner members", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "Tech Goose",
respondTo: "anyone",
channelNames: ["general"],
memberChannelNames: ["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("@Tech");

const dropdown = autocomplete(page);
await expect(dropdown.getByText("Tech Goose")).toBeVisible();
await expect(dropdown.getByText("agent")).toBeVisible();
});

test("relay-only agents stay hidden from channel mentions even when allowlisted", async ({
page,
}) => {
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ type MockRelayAgentSeed = {
respondToAllowlist?: string[];
channelNames?: string[];
channelIds?: string[];
/** Also seed this relay-owned agent as a bot member of the named channels. */
memberChannelNames?: string[];
status?: "online" | "away" | "offline";
};

Expand Down