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 @@ -3,11 +3,15 @@ import test from "node:test";

import {
coalesceAgentAutocompleteCandidates,
filterCachedAgentSuggestions,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
isAgentIdentityInAllowedList,
isAgentMentionChannelType,
relayAgentCanRespondInChannel,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
uniqueAutocompleteLabels,
} from "./agentAutocompleteEligibility.ts";

const CURRENT_PUBKEY = "a".repeat(64);
Expand Down Expand Up @@ -106,8 +110,30 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user"
);
});

test("relayAgentCanRespondInChannel: requires exact channel membership and viewer access", () => {
const agent = {
respondTo: "allowlist",
respondToAllowlist: [CURRENT_PUBKEY],
channelIds: ["general"],
};

assert.equal(
relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY),
true,
);
assert.equal(
relayAgentCanRespondInChannel(agent, "other", CURRENT_PUBKEY),
false,
);
assert.equal(
relayAgentCanRespondInChannel(agent, "general", OTHER_OWNER_PUBKEY),
false,
);
});

test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => {
const result = getMentionableAgentPubkeys({
eligibilityScope: { type: "community" },
managedAgentPubkeys: [PUB_A],
currentPubkey: CURRENT_PUBKEY,
relayAgents: [
Expand Down Expand Up @@ -136,27 +162,94 @@ 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", () => {
const managedAgentPubkeys = new Set([PUB_A]);
test("getMentionableAgentPubkeys: scopes channel composers and fails closed without context", () => {
const relayAgents = [
{
pubkey: PUB_B,
respondTo: "allowlist",
respondToAllowlist: [CURRENT_PUBKEY],
channelIds: ["general"],
},
];
const base = {
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys: [PUB_A],
relayAgents,
sharedChannelIds: new Set(["general"]),
};

assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "channel", channelId: "general" },
}),
new Set([PUB_A, PUB_B]),
);
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "channel", channelId: "other" },
}),
new Set([PUB_A]),
);
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "managed-only" },
}),
new Set([PUB_A]),
);
});

test("autocomplete helper extraction preserves safe filtering and labels", () => {
assert.equal(isAgentMentionChannelType("stream"), true);
assert.equal(isAgentMentionChannelType("forum"), true);
assert.equal(isAgentMentionChannelType("dm"), false);
assert.equal(isAgentMentionChannelType(null), false);

assert.deepEqual(
uniqueAutocompleteLabels([
{ displayName: " Alice ", personaName: "alice" },
{ displayName: null, secondaryLabel: "Bob" },
{ displayName: "BOB" },
]),
["Alice", "Bob"],
);

const person = { pubkey: PUB_A, isAgent: false };
const admittedAgent = { pubkey: PUB_B.toUpperCase(), isAgent: true };
const removedAgent = { pubkey: PUB_C, isAgent: true };
const persona = { isAgent: true };
assert.deepEqual(
filterCachedAgentSuggestions(
[person, admittedAgent, removedAgent, persona],
[{ pubkey: PUB_B, isAgent: true }],
),
[person, admittedAgent, persona],
);
});

test("isAgentIdentityInAllowedList: keeps people and only explicitly allowed agent identities", () => {
const allowedAgentPubkeys = new Set([PUB_A]);

assert.equal(
isAgentIdentityInManagedList(
isAgentIdentityInAllowedList(
{ isAgent: false, pubkey: PUB_B },
managedAgentPubkeys,
allowedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
isAgentIdentityInAllowedList(
{ isAgent: true, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
allowedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
isAgentIdentityInAllowedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
allowedAgentPubkeys,
),
false,
);
Expand Down
85 changes: 81 additions & 4 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,31 @@ export function relayAgentIsSharedWithUser(
);
}

export function relayAgentCanRespondInChannel(
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
channelId: string,
currentPubkey?: string | null,
) {
return (
agent.channelIds.includes(channelId) &&
relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey)
);
}

export type AgentEligibilityScope =
| { type: "community" }
| { type: "channel"; channelId: string }
| { type: "managed-only" };

export function getMentionableAgentPubkeys({
currentPubkey,
eligibilityScope,
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
}: {
currentPubkey?: string | null;
eligibilityScope: AgentEligibilityScope;
managedAgentPubkeys: Iterable<string>;
relayAgents: readonly RelayAgent[] | undefined;
sharedChannelIds: ReadonlySet<string>;
Expand All @@ -46,21 +64,31 @@ export function getMentionableAgentPubkeys({
);

for (const agent of relayAgents ?? []) {
if (relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
const isAllowed =
eligibilityScope.type === "managed-only"
? false
: eligibilityScope.type === "community"
? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)
: relayAgentCanRespondInChannel(
agent,
eligibilityScope.channelId,
currentPubkey,
);
if (isAllowed) {
pubkeys.add(normalizePubkey(agent.pubkey));
}
}

return pubkeys;
}

export function isAgentIdentityInManagedList(
export function isAgentIdentityInAllowedList(
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 Expand Up @@ -97,9 +125,58 @@ export function shouldHideAgentFromMentions({
return directoryAgentPubkeys.has(normalized);
}

export function isAgentMentionChannelType(type?: string | null) {
return type === "stream" || type === "forum";
}

export function uniqueAutocompleteLabels(
candidates: readonly AgentAutocompleteCandidate[],
) {
const unique = new Map<string, string>();
for (const candidate of candidates) {
for (const label of [
candidate.displayName,
candidate.personaName,
candidate.secondaryLabel,
]) {
const trimmed = label?.trim();
if (trimmed && !unique.has(trimmed.toLowerCase())) {
unique.set(trimmed.toLowerCase(), trimmed);
}
}
}
return [...unique.values()];
}

export function filterCachedAgentSuggestions<
T extends {
isAgent?: boolean;
pubkey?: string;
},
>(
suggestions: readonly T[],
currentCandidates: readonly AgentAutocompleteCandidate[],
) {
const admittedAgentPubkeys = new Set(
currentCandidates.flatMap((candidate) =>
candidate.isAgent && candidate.pubkey
? [normalizePubkey(candidate.pubkey)]
: [],
),
);
return suggestions.filter(
(suggestion) =>
!suggestion.isAgent ||
!suggestion.pubkey ||
admittedAgentPubkeys.has(normalizePubkey(suggestion.pubkey)),
);
}

type AgentAutocompleteCandidate = {
pubkey?: string;
displayName?: string | null;
personaName?: string | null;
secondaryLabel?: string | null;
ownerPubkey?: string | null;
isAgent?: boolean;
isManagedAgent?: boolean;
Expand Down
21 changes: 17 additions & 4 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import {
invalidateChannelState,
useAddChannelMembersMutation,
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import { attachManagedAgentToChannel } from "@/features/agents/channelAgents";
import {
coalesceAgentAutocompleteCandidates,
isAgentIdentityInManagedList,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInAllowedList,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
Expand Down Expand Up @@ -155,6 +158,7 @@ export function MembersSidebar({
>(() => new Set());
const identityQuery = useIdentityQuery();
const membersQuery = useChannelMembersQuery(channelId, open);
const channelsQuery = useChannelsQuery({ enabled: open });
const addMembersMutation = useAddChannelMembersMutation(channelId);
const changeRoleMutation = useMutation({
mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => {
Expand Down Expand Up @@ -271,7 +275,14 @@ export function MembersSidebar({
.map((member) => member.displayName?.trim().toLowerCase())
.filter((label): label is string => Boolean(label)),
);
const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys());
const sharedChannelIds = getSharedChannelIds(channelsQuery.data);
const allowedAgentPubkeys = getMentionableAgentPubkeys({
currentPubkey,
eligibilityScope: { type: "community" },
managedAgentPubkeys: managedAgentsByPubkey.keys(),
relayAgents: relayAgentsQuery.data,
sharedChannelIds,
});

const addCandidate = (candidate: AddMemberSearchCandidate) => {
const pubkey = normalizePubkey(candidate.pubkey);
Expand All @@ -282,7 +293,7 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
!isAgentIdentityInAllowedList(candidate, allowedAgentPubkeys)
) {
return;
}
Expand Down Expand Up @@ -361,6 +372,7 @@ export function MembersSidebar({
});
}, [
canAddMembers,
channelsQuery.data,
isArchivedDiscovery,
currentPubkey,
managedAgentsQuery.data,
Expand All @@ -373,7 +385,8 @@ export function MembersSidebar({
const isAddSearchLoading =
userSearchQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading;
relayAgentsQuery.isLoading ||
channelsQuery.isLoading;
const handlePeopleSearchScroll = useUserSearchFetchMoreOnScroll(
userSearchQuery,
canAddMembers && normalizedDeferredSearchQuery.length > 0,
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useCompactComposerInteractions } from "./useCompactComposerInteractions

export function ForumComposer({
channelId = null,
channelType,
members,
className,
placeholder,
Expand Down Expand Up @@ -69,7 +70,7 @@ export function ForumComposer({
if (compact) setIsCompactExpanded(true);
}, [compact]);

const mentions = useMentions(channelId, members, profiles);
const mentions = useMentions(channelId, members, profiles, { channelType });
const channelLinks = useChannelLinks();
const media = useMediaUpload();
const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } =
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/forum/ui/ForumComposer.types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type * as React from "react";

import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelMember } from "@/shared/api/types";
import type { ChannelMember, ChannelType } from "@/shared/api/types";

export type ForumComposerProps = {
channelId?: string | null;
/** Known channel type for channel-backed composers; omitted uses fail closed. */
channelType?: ChannelType | null;
/** Override mention source when no channel is available (e.g. Pulse). */
members?: ChannelMember[];
className?: string;
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/forum/ui/ForumThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export function ForumThreadPanel({
<div className="border-t border-border/60 p-4">
<ForumComposer
channelId={channelId}
channelType="forum"
isSending={isSendingReply}
onSubmit={onReply}
placeholder="Reply to this post..."
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/forum/ui/ForumView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function ForumView({
<ForumComposer
autocompleteBelow
channelId={channel.id}
channelType="forum"
isSending={createPostMutation.isPending}
onCancel={() => setIsComposerOpen(false)}
onSubmit={async (content, mentionPubkeys, mediaTags) => {
Expand Down
Loading