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 @@ -5,7 +5,7 @@ import {
coalesceAgentAutocompleteCandidates,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
isAgentAutocompleteEligible,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
} from "./agentAutocompleteEligibility.ts";
Expand Down Expand Up @@ -136,25 +136,32 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents",
assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C]));
});

test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => {
test("isAgentAutocompleteEligible: keeps people, channel members, and current managed agent identities", () => {
const managedAgentPubkeys = new Set([PUB_A]);

assert.equal(
isAgentIdentityInManagedList(
isAgentAutocompleteEligible(
{ isAgent: false, pubkey: PUB_B },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
isAgentAutocompleteEligible(
{ isAgent: true, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
isAgentAutocompleteEligible(
{ isAgent: true, isMember: true, pubkey: PUB_B },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentAutocompleteEligible(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
),
Expand Down Expand Up @@ -306,3 +313,80 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => {

assert.deepEqual(coalesce([first, second]), [first, second]);
});

// ── Composed gate: relay-only agents must survive both checks ──────────────
//
// Regression for the case where a viewer who manages no agents locally could
// never mention an agent. `useMentions` runs `isAgentAutocompleteEligible`
// before `shouldHideAgentFromMentions`, so that first gate must receive
// `getMentionableAgentPubkeys(...)` (local managed ∪ relay agents shared with
// the viewer). The channel-member clause alone does not cover a relay agent
// that is invocable but not a member of the channel being typed in.

test("relay agent allowlisting the viewer survives the composed mention gate", () => {
const managedAgentPubkeys = []; // viewer manages no agents locally
const relayAgents = [
{
pubkey: PUB_C,
respondTo: "allowlist",
respondToAllowlist: [CURRENT_PUBKEY],
channelIds: [],
},
];
const mentionableAgentPubkeys = getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys,
relayAgents,
sharedChannelIds: new Set(["general"]),
});
const candidate = { isAgent: true, isMember: false, pubkey: PUB_C };

assert.equal(
isAgentAutocompleteEligible(candidate, mentionableAgentPubkeys),
true,
"relay agent that allowlists the viewer must pass the first gate",
);
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: false,
pubkey: PUB_C,
mentionableAgentPubkeys,
directoryAgentPubkeys: new Set([PUB_C]),
}),
false,
"an invocable relay agent must not be hidden",
);

// The pre-fix behaviour, pinned so the regression cannot return quietly.
assert.equal(
isAgentAutocompleteEligible(candidate, new Set(managedAgentPubkeys)),
false,
"local-only set drops the relay agent — this is why the gate must not use it",
);
});

test("relay agent that excludes the viewer stays hidden", () => {
const mentionableAgentPubkeys = getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys: [],
relayAgents: [
{
pubkey: PUB_C,
respondTo: "allowlist",
respondToAllowlist: [OTHER_OWNER_PUBKEY],
channelIds: [],
},
],
sharedChannelIds: new Set(["general"]),
});

assert.equal(
isAgentAutocompleteEligible(
{ isAgent: true, isMember: false, pubkey: PUB_C },
mentionableAgentPubkeys,
),
false,
"widening the gate must not expose agents that exclude the viewer",
);
});
19 changes: 15 additions & 4 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,24 @@ export function getMentionableAgentPubkeys({
return pubkeys;
}

export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
/**
* First-pass autocomplete gate. Non-agents and channel members always pass;
* any other agent must appear in `allowedAgentPubkeys`.
*
* Callers decide what "allowed" means, and the choice is load-bearing. Mention
* autocomplete must pass `getMentionableAgentPubkeys(...)` (local managed ∪
* relay agents shared with the viewer) — passing the locally-managed set alone
* drops every relay agent before `shouldHideAgentFromMentions` can admit it,
* so a viewer who manages no agents locally can mention none of them.
*/
export function isAgentAutocompleteEligible(
candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string },
allowedAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
candidate.isMember === true ||
allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
}

Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { attachManagedAgentToChannel } from "@/features/agents/channelAgents";
import {
coalesceAgentAutocompleteCandidates,
isAgentIdentityInManagedList,
isAgentAutocompleteEligible,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
Expand Down Expand Up @@ -282,7 +282,7 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
!isAgentAutocompleteEligible(candidate, managedAgentPubkeys)
) {
return;
}
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,
isAgentAutocompleteEligible,
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 (!isAgentAutocompleteEligible(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