Skip to content
Merged
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 @@ -138,30 +138,70 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents",

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

assert.equal(
isAgentIdentityInManagedList(
{ isAgent: false, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
false,
);
});

test("isAgentIdentityInManagedList: admits invocable relay agents via the mentionable set", () => {
const managedAgentPubkeys = new Set([PUB_A]);
// PUB_B is a box-run relay-directory agent: shared with the viewer (so it is
// invocable and lands in the mentionable set) but absent from the viewer's
// local managed list.
const mentionableAgentPubkeys = new Set([PUB_A, PUB_B]);

// (a) isAgent, in the mentionable-but-not-managed set => admitted.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B.toUpperCase() },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
// (b) isAgent, in neither set => dropped.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_C },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
false,
);
// (c) non-agent => unaffected, always admitted.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: false, pubkey: PUB_C },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
31 changes: 29 additions & 2 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,40 @@ export function getMentionableAgentPubkeys({
return pubkeys;
}

/**
* Gate for agent identities surfaced in mention autocomplete and the
* add-members picker. Non-agent candidates always pass. An agent candidate is
* admitted when its normalized pubkey is reachable by the viewer through
* EITHER of two paths:
*
* 1. `managedAgentPubkeys` — agents the viewer manages locally. This is the
* original #2149 gate: a bare kind:0 owner-auth badge (`isAgent === true`)
* must not, on its own, surface an agent the viewer has no relationship to.
* 2. `mentionableAgentPubkeys` — agents the viewer can actually invoke: the
* locally-managed set unioned with relay-directory (kind:10100) agents
* shared with the viewer (`respond_to: "anyone"` in a shared channel, or an
* allowlist that includes the viewer). See `getMentionableAgentPubkeys`.
*
* Path 2 exists for box-run resident agents: NIP-OA-badged agents that live on
* a shared relay and publish a proper directory card. They are never in any
* single viewer's local managed list, yet they are legitimately invocable and
* must stay visible to mention and add. Without this path #2149 drops them
* purely because they are flagged `isAgent` and absent from the local managed
* list. Admitting via either set preserves #2149's intent (a bare agent badge
* still is not enough) while restoring reachable directory agents.
*/
export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
mentionableAgentPubkeys: ReadonlySet<string>,
) {
if (candidate.isAgent !== true) {
return true;
}
const normalized = normalizePubkey(candidate.pubkey);
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
managedAgentPubkeys.has(normalized) ||
mentionableAgentPubkeys.has(normalized)
);
}

Expand Down
21 changes: 20 additions & 1 deletion desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { Bot, UserRoundPlus, X } from "lucide-react";
import {
useAddChannelMembersMutation,
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import {
coalesceAgentAutocompleteCandidates,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
Expand Down Expand Up @@ -143,6 +146,7 @@ export function MembersSidebar({
>(() => new Set());
const identityQuery = useIdentityQuery();
const membersQuery = useChannelMembersQuery(channelId, open);
const channelsQuery = useChannelsQuery();
const addMembersMutation = useAddChannelMembersMutation(channelId);
const changeRoleMutation = useMutation({
mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => {
Expand Down Expand Up @@ -260,6 +264,16 @@ export function MembersSidebar({
.filter((label): label is string => Boolean(label)),
);
const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys());
// Invocable set = locally-managed ∪ relay-directory agents shared with the
// viewer. Lets box-run resident agents (NIP-OA-badged, kind:10100 card,
// shared channel) pass the agent gate even though they are absent from the
// local managed list. Mirrors useMentions' mention-autocomplete gate.
const mentionableAgentPubkeys = getMentionableAgentPubkeys({
currentPubkey,
managedAgentPubkeys,
relayAgents: relayAgentsQuery.data,
sharedChannelIds: getSharedChannelIds(channelsQuery.data),
});

const addCandidate = (candidate: AddMemberSearchCandidate) => {
const pubkey = normalizePubkey(candidate.pubkey);
Expand All @@ -270,7 +284,11 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
!isAgentIdentityInManagedList(
candidate,
managedAgentPubkeys,
mentionableAgentPubkeys,
)
) {
return;
}
Expand Down Expand Up @@ -349,6 +367,7 @@ export function MembersSidebar({
});
}, [
canAddMembers,
channelsQuery.data,
isArchivedDiscovery,
currentPubkey,
managedAgentsQuery.data,
Expand Down
25 changes: 12 additions & 13 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,35 +188,28 @@ export function useMentions(
),
[relayAgentsQuery.data],
);
const sharedChannelIds = React.useMemo(
() => getSharedChannelIds(channelsQuery.data),
[channelsQuery.data],
);
const mentionableAgentPubkeys = React.useMemo(
() =>
getMentionableAgentPubkeys({
currentPubkey,
managedAgentPubkeys,
relayAgents: relayAgentsQuery.data,
sharedChannelIds,
sharedChannelIds: getSharedChannelIds(channelsQuery.data),
}),
[
currentPubkey,
managedAgentPubkeys,
relayAgentsQuery.data,
sharedChannelIds,
channelsQuery.data,
],
);
const personaNameByPubkey = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
const personas = personasQuery.data ?? [];
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
const lookup = new Map<string, string>();
for (const agent of agents) {
if (agent.personaId) {
const name = personaById.get(agent.personaId);
if (name) lookup.set(normalizePubkey(agent.pubkey), name);
}
for (const agent of managedAgentsQuery.data ?? []) {
const name = agent.personaId && personaById.get(agent.personaId);
if (name) lookup.set(normalizePubkey(agent.pubkey), name);
}
return lookup;
}, [managedAgentsQuery.data, personasQuery.data]);
Expand Down Expand Up @@ -246,7 +239,13 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
if (
!isAgentIdentityInManagedList(
candidate,
managedAgentPubkeys,
mentionableAgentPubkeys,
)
) {
return;
}
if (
Expand Down