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,
relayAgentIsInvocableByUser,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
} from "./agentAutocompleteEligibility.ts";
Expand Down Expand Up @@ -306,3 +307,64 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => {

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

test("relayAgentIsInvocableByUser: anyone responds regardless of channel overlap", () => {
assert.equal(
relayAgentIsInvocableByUser(
{ respondTo: "anyone", respondToAllowlist: [] },
CURRENT_PUBKEY,
),
true,
);
});

test("relayAgentIsInvocableByUser: allowlist honors membership", () => {
const agent = { respondTo: "allowlist", respondToAllowlist: [CURRENT_PUBKEY] };
assert.equal(relayAgentIsInvocableByUser(agent, CURRENT_PUBKEY), true);
assert.equal(relayAgentIsInvocableByUser(agent, OTHER_OWNER_PUBKEY), false);
});

test("relayAgentIsInvocableByUser: owner-only and unknown modes are not invocable", () => {
assert.equal(
relayAgentIsInvocableByUser(
{ respondTo: "owner-only", respondToAllowlist: [] },
CURRENT_PUBKEY,
),
false,
);
assert.equal(
relayAgentIsInvocableByUser({ respondTo: null, respondToAllowlist: [] }, CURRENT_PUBKEY),
false,
);
});

test("shouldHideAgentFromMentions: relay-shared agent shows without local managed entry", () => {
// Regression: a shared agent owned by another member (directory-eligible,
// respond_to=anyone, shared channel) must be mentionable even though it is
// not in this device's managed-agents list.
const sharedChannelIds = new Set(["chan-1"]);
const relayAgents = [
{
pubkey: PUB_D,
respondTo: "anyone",
respondToAllowlist: [],
channelIds: ["chan-1"],
},
];
const mentionable = getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys: [],
relayAgents,
sharedChannelIds,
});
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: true,
pubkey: PUB_D,
mentionableAgentPubkeys: mentionable,
directoryAgentPubkeys: new Set([PUB_D]),
}),
false,
);
});
30 changes: 25 additions & 5 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
);
}

export function relayAgentIsSharedWithUser(
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
sharedChannelIds: ReadonlySet<string>,
/// Whether the relay directory says this agent would respond to the current
/// user at all, ignoring channel overlap. Used where the user is about to
/// CREATE the overlap (e.g. adding the agent to a channel), so requiring an
/// existing shared channel would be circular.
export function relayAgentIsInvocableByUser(
agent: Pick<RelayAgent, "respondTo" | "respondToAllowlist">,
currentPubkey?: string | null,
) {
const normalizedCurrentPubkey = currentPubkey
Expand All @@ -24,6 +27,18 @@ export function relayAgentIsSharedWithUser(
.includes(normalizedCurrentPubkey);
}

return agent.respondTo === "anyone";
}

export function relayAgentIsSharedWithUser(
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
sharedChannelIds: ReadonlySet<string>,
currentPubkey?: string | null,
) {
if (agent.respondTo === "allowlist") {
return relayAgentIsInvocableByUser(agent, currentPubkey);
}

return (
agent.respondTo === "anyone" &&
agent.channelIds.some((channelId) => sharedChannelIds.has(channelId))
Expand Down Expand Up @@ -54,13 +69,18 @@ export function getMentionableAgentPubkeys({
return pubkeys;
}

/// Non-agents always pass; agent identities pass when their pubkey is in the
/// provided allow-set. Callers decide the set: the local managed list alone is
/// NOT sufficient — relay-directory agents that would respond to this user
/// (see `relayAgentIsInvocableByUser`) must be included, otherwise shared
/// agents owned by other members are silently unreachable.
export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
allowedAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
}

Expand Down
13 changes: 11 additions & 2 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { attachManagedAgentToChannel } from "@/features/agents/channelAgents";
import {
coalesceAgentAutocompleteCandidates,
isAgentIdentityInManagedList,
relayAgentIsInvocableByUser,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
Expand Down Expand Up @@ -271,7 +272,15 @@ export function MembersSidebar({
.map((member) => member.displayName?.trim().toLowerCase())
.filter((label): label is string => Boolean(label)),
);
const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys());
// Agents addable to this channel: locally managed ones plus relay-directory
// agents that would respond to this user. Channel overlap is deliberately
// not required — adding the agent to this channel is what creates it.
const addableAgentPubkeys = new Set(managedAgentsByPubkey.keys());
for (const agent of relayAgentsQuery.data ?? []) {
if (relayAgentIsInvocableByUser(agent, currentPubkey)) {
addableAgentPubkeys.add(normalizePubkey(agent.pubkey));
}
}

const addCandidate = (candidate: AddMemberSearchCandidate) => {
const pubkey = normalizePubkey(candidate.pubkey);
Expand All @@ -282,7 +291,7 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
!isAgentIdentityInManagedList(candidate, addableAgentPubkeys)
) {
return;
}
Expand Down
10 changes: 6 additions & 4 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
coalesceAutocompleteCandidatesByKey,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
shouldHideAgentFromMentions,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import {
Expand Down Expand Up @@ -246,9 +245,12 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
return;
}
// NOTE: no pre-emptive managed-list gate here. `shouldHideAgentFromMentions`
// is the single authority: it already shows invocable agents (local managed
// OR relay-directory-eligible via `mentionableAgentPubkeys`), hides
// non-member non-invocable ones, and applies the member/directory rules.
// An earlier managed-list-only gate made shared agents owned by other
// members unreachable from the composer.
if (
shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
Expand Down