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 All @@ -17,6 +18,7 @@ const PUB_A = "1".repeat(64);
const PUB_B = "2".repeat(64);
const PUB_C = "3".repeat(64);
const PUB_D = "4".repeat(64);
const PUB_HEX = "ab".repeat(32);

function coalesce(candidates, options = {}) {
return coalesceAgentAutocompleteCandidates(candidates, {
Expand Down Expand Up @@ -162,6 +164,52 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent
);
});

test("isAgentIdentityMentionable: keeps people, managed agents, and mentionable relay agents", () => {
// Mirrors useMentions: the mentionable set from getMentionableAgentPubkeys
// is managed agents (PUB_A, PUB_HEX) plus shared relay agents (PUB_B).
const mentionableAgentPubkeys = new Set([PUB_A, PUB_B, PUB_HEX]);

assert.equal(
isAgentIdentityMentionable(
{ isAgent: false, pubkey: PUB_C },
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, pubkey: PUB_HEX.toUpperCase() },
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, pubkey: PUB_B },
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, pubkey: PUB_C },
mentionableAgentPubkeys,
),
false,
);
// Member agent with no usable directory record: isMember does not bypass
// the gate — an agent identity outside the mentionable set is filtered
// here, before shouldHideAgentFromMentions' member branch can run
// (pre-existing behavior, deliberately pinned).
assert.equal(
isAgentIdentityMentionable(
{ isAgent: true, isMember: true, pubkey: PUB_D },
mentionableAgentPubkeys,
),
false,
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down Expand Up @@ -214,6 +262,10 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo
);
});

// Note: in useMentions' addCandidate flow this member branch only runs for
// candidates that already passed isAgentIdentityMentionable, which requires
// mentionable-set membership — so this unit behavior is currently unreachable
// end-to-end there (see the pinned member-agent case above).
test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
19 changes: 19 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,25 @@ export function isAgentIdentityInManagedList(
);
}

/**
* Managed-list membership alone cannot admit remote agents: managed agents
* are never minted from relay events (see apply_inbound_managed_agent), so a
* shared relay agent has no local record on this device. Mention candidacy
* therefore checks the mentionable set from getMentionableAgentPubkeys —
* locally managed agents plus directory-mentionable relay agents, the same
* eligibility `useNewMessageRecipients` already trusts. An agent identity
* outside that set stays hidden.
*/
export function isAgentIdentityMentionable(
candidate: { isAgent?: boolean; pubkey: string },
mentionableAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
mentionableAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
}

export function shouldHideAgentFromMentions({
isAgent,
isMember,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
BOB,
);

assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer");
assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`);
assert.equal(personas[0].isActive, false);
});

Expand All @@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => {
ALICE,
);

assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer");
assert.equal(personas[0].id, `catalog:${BOB}:reviewer`);
assert.equal(personas[0].isActive, false);
});

Expand Down
5 changes: 2 additions & 3 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, mentionableAgentPubkeys)) {
return;
}
if (
Expand Down Expand Up @@ -420,7 +420,6 @@ export function useMentions(
managedAgentNamesByPubkey,
managedAgentPersonaIds,
managedAgentPersonaIdsByPubkey,
managedAgentPubkeys,
managedAgentsQuery.data,
memberPubkeys,
members,
Expand Down
10 changes: 9 additions & 1 deletion desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ 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 shared relay agent (directory respond_to: "anyone" with a
// shared channel) and a channel member: mentionable even though she is
// not locally managed.
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 All @@ -227,6 +230,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m
const suggestionText = await suggestions.allInnerTexts();
const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz"));
const bobIndex = suggestionText.findIndex((text) => text.includes("bob"));
const aliceIndex = suggestionText.findIndex((text) => text.includes("alice"));
const charlieIndex = suggestionText.findIndex((text) =>
text.includes("charlie"),
);
Expand All @@ -235,9 +239,13 @@ test("@ trigger prioritizes channel members before runnable personas and other m
);
expect(fizzIndex).toBeGreaterThanOrEqual(0);
expect(bobIndex).toBeGreaterThanOrEqual(0);
expect(aliceIndex).toBeGreaterThanOrEqual(0);
expect(charlieIndex).toBeGreaterThanOrEqual(0);
expect(outsiderIndex).toEqual(-1);
expect(bobIndex).toBeLessThan(fizzIndex);
// alice is a channel member, so she sorts in the member tier ahead of
// personas and non-member managed agents.
expect(aliceIndex).toBeLessThan(fizzIndex);
expect(fizzIndex).toBeLessThan(charlieIndex);
});

Expand Down