Skip to content
Draft
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
491 changes: 491 additions & 0 deletions crates/buzz-acp/src/lib.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,70 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => {

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

test("relayAgentCanRespondInChannel: the live roster beats a not-yet-polled channelIds", () => {
// The agent was invited to "fresh", but the relay-agents query last ran
// before that and still reports only "general". The roster is live.
const agent = {
pubkey: PUB_A,
respondTo: "anyone",
respondToAllowlist: [],
channelIds: ["general"],
};

assert.equal(
relayAgentCanRespondInChannel(agent, "fresh", CURRENT_PUBKEY),
false,
);
assert.equal(
relayAgentCanRespondInChannel(
agent,
"fresh",
CURRENT_PUBKEY,
new Set([PUB_A]),
),
true,
);
});

test("relayAgentCanRespondInChannel: membership does not bypass an allowlist", () => {
const agent = {
pubkey: PUB_A,
respondTo: "allowlist",
respondToAllowlist: [OTHER_OWNER_PUBKEY],
channelIds: ["general"],
};

assert.equal(
relayAgentCanRespondInChannel(
agent,
"fresh",
CURRENT_PUBKEY,
new Set([PUB_A]),
),
false,
);
});

test("getMentionableAgentPubkeys: channel scope accepts a member the agents poll has not caught up to", () => {
const agent = {
pubkey: PUB_A,
respondTo: "anyone",
respondToAllowlist: [],
channelIds: ["general"],
};

assert.deepEqual(
[
...getMentionableAgentPubkeys({
channelMemberPubkeys: new Set([PUB_A]),
currentPubkey: CURRENT_PUBKEY,
eligibilityScope: { type: "channel", channelId: "fresh" },
managedAgentPubkeys: [],
relayAgents: [agent],
sharedChannelIds: new Set(["fresh"]),
}),
],
[PUB_A],
);
});
34 changes: 30 additions & 4 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,27 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
);
}

/**
* `sharesChannel` lets a caller assert the shared channel from the live channel
* roster instead of `agent.channelIds`.
*
* Both are relay membership — `list_relay_agents` replaces `channelIds` with
* the membership it reads from relay-signed kind:39002 before the frontend sees
* it — but they refresh on different clocks. `useRelayAgentsQuery` polls every
* `AGENTS_FOCUS_STALE_TIME_MS` (5 min) with `refetchOnWindowFocus: false`,
* while the roster updates live. An agent invited to the channel you are
* looking at is therefore missing from the picker until the next poll tick.
* The roster closes that window. `respondTo` is still enforced, so this widens
* discovery, not authority.
*/
export function relayAgentIsSharedWithUser(
agent: Pick<
RelayAgent,
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
>,
sharedChannelIds: ReadonlySet<string>,
currentPubkey?: string | null,
sharesChannel = false,
) {
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
Expand All @@ -37,21 +51,30 @@ export function relayAgentIsSharedWithUser(

return (
agent.respondTo === "anyone" &&
agent.channelIds.some((channelId) => sharedChannelIds.has(channelId))
(sharesChannel ||
agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)))
);
}

export function relayAgentCanRespondInChannel(
agent: Pick<
RelayAgent,
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
"channelIds" | "ownerPubkey" | "pubkey" | "respondTo" | "respondToAllowlist"
>,
channelId: string,
currentPubkey?: string | null,
channelMemberPubkeys?: ReadonlySet<string>,
) {
const isChannelMember =
channelMemberPubkeys?.has(normalizePubkey(agent.pubkey)) === true;
return (
agent.channelIds.includes(channelId) &&
relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey)
(isChannelMember || agent.channelIds.includes(channelId)) &&
relayAgentIsSharedWithUser(
agent,
new Set([channelId]),
currentPubkey,
isChannelMember,
)
);
}

Expand All @@ -61,12 +84,14 @@ export type AgentEligibilityScope =
| { type: "managed-only" };

export function getMentionableAgentPubkeys({
channelMemberPubkeys,
currentPubkey,
eligibilityScope,
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
}: {
channelMemberPubkeys?: ReadonlySet<string>;
currentPubkey?: string | null;
eligibilityScope: AgentEligibilityScope;
managedAgentPubkeys: Iterable<string>;
Expand All @@ -87,6 +112,7 @@ export function getMentionableAgentPubkeys({
agent,
eligibilityScope.channelId,
currentPubkey,
channelMemberPubkeys,
);
if (isAllowed) {
pubkeys.add(normalizePubkey(agent.pubkey));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type DirectoryResult<T> = {
export async function revalidateAgentMentionPubkeys({
pubkeys,
agentPubkeys,
channelMemberPubkeys,
currentPubkey,
eligibilityScope,
sharedChannelIds,
Expand All @@ -25,6 +26,7 @@ export async function revalidateAgentMentionPubkeys({
}: {
pubkeys: readonly string[];
agentPubkeys: ReadonlySet<string>;
channelMemberPubkeys?: ReadonlySet<string>;
currentPubkey: string | null;
eligibilityScope: AgentEligibilityScope;
sharedChannelIds: ReadonlySet<string>;
Expand All @@ -51,6 +53,7 @@ export async function revalidateAgentMentionPubkeys({
managedResult.data.map((agent) => normalizePubkey(agent.pubkey)),
);
const mentionablePubkeys = getMentionableAgentPubkeys({
channelMemberPubkeys,
currentPubkey,
eligibilityScope,
managedAgentPubkeys: managedPubkeys,
Expand All @@ -76,13 +79,15 @@ export async function revalidateAgentMentionPubkeys({

export function useAgentMentionRevalidation({
agentPubkeys,
channelMemberPubkeys,
getSelectedAgentPubkeys,
currentPubkey,
eligibilityScope,
sharedChannelIds,
refetchManagedAgents,
}: {
agentPubkeys: ReadonlySet<string>;
channelMemberPubkeys?: ReadonlySet<string>;
getSelectedAgentPubkeys: () => ReadonlySet<string>;
currentPubkey: string | null;
eligibilityScope: AgentEligibilityScope;
Expand All @@ -94,6 +99,7 @@ export function useAgentMentionRevalidation({
revalidateAgentMentionPubkeys({
pubkeys,
agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]),
channelMemberPubkeys,
currentPubkey,
eligibilityScope,
sharedChannelIds,
Expand All @@ -108,6 +114,7 @@ export function useAgentMentionRevalidation({
}),
[
agentPubkeys,
channelMemberPubkeys,
currentPubkey,
eligibilityScope,
getSelectedAgentPubkeys,
Expand Down
24 changes: 11 additions & 13 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type { AutocompleteEdit } from "./useRichTextEditor";
import type { ChannelMember, ChannelType } from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { normalizePubkey, normalizePubkeySet } from "@/shared/lib/pubkey";
import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { flushMentionDebounce } from "./flushMentionDebounce";
Expand Down Expand Up @@ -152,12 +152,7 @@ export function useMentions(
[managedAgentsQuery.data],
);
const managedAgentPubkeys = React.useMemo(
() =>
new Set(
(managedAgentsQuery.data ?? []).map((agent) =>
normalizePubkey(agent.pubkey),
),
),
() => normalizePubkeySet(managedAgentsQuery.data),
[managedAgentsQuery.data],
);
const relayAgentNamesByPubkey = React.useMemo(
Expand All @@ -177,9 +172,16 @@ export function useMentions(
const mentionChannelId = isAgentMentionChannelType(options?.channelType)
? channelId
: null;
// Identity-cached (shared with the timeline's roster derivations) — the
// Set is built once per distinct roster instead of per consumer.
const memberPubkeys = React.useMemo(
() => (members ? channelMemberPubkeySet(members) : new Set<string>()),
[members],
);
const mentionableAgentPubkeys = React.useMemo(
() =>
getMentionableAgentPubkeys({
channelMemberPubkeys: memberPubkeys,
currentPubkey,
eligibilityScope: mentionChannelId
? { type: "channel", channelId: mentionChannelId }
Expand All @@ -191,6 +193,7 @@ export function useMentions(
[
currentPubkey,
managedAgentPubkeys,
memberPubkeys,
mentionChannelId,
relayAgentsQuery.data,
sharedChannelIds,
Expand Down Expand Up @@ -225,12 +228,6 @@ export function useMentions(
() => new Set(activePersonas.map((persona) => persona.id)),
[activePersonas],
);
// Identity-cached (shared with the timeline's roster derivations) — the
// Set is built once per distinct roster instead of per consumer.
const memberPubkeys = React.useMemo(
() => (members ? channelMemberPubkeySet(members) : new Set<string>()),
[members],
);
const agentIdentityPubkeys = React.useMemo(
() =>
getAgentIdentityPubkeys({
Expand Down Expand Up @@ -809,6 +806,7 @@ export function useMentions(
).current;
const revalidateMentionPubkeys = useAgentMentionRevalidation({
agentPubkeys: agentIdentityPubkeys,
channelMemberPubkeys: memberPubkeys,
getSelectedAgentPubkeys,
currentPubkey,
eligibilityScope: mentionChannelId
Expand Down
69 changes: 69 additions & 0 deletions desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
deriveProfileChannels,
parseProfilePanelTab,
parseProfilePanelView,
personaManagedAgentUpdate,
Expand Down Expand Up @@ -244,3 +245,71 @@ test("profile target identity stays stable while a requested pubkey is canonical
"persona:requested-persona",
);
});

const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";

function relayAgent(overrides = {}) {
return {
pubkey: "aa".repeat(32),
ownerPubkey: null,
name: "Fizz",
agentType: "agent",
channels: [],
channelIds: [],
capabilities: [],
status: "offline",
respondTo: "anyone",
respondToAllowlist: [],
...overrides,
};
}

const COMMUNITY_CHANNELS = [
{ id: CHANNEL_A, name: "general" },
{ id: CHANNEL_B, name: "random" },
];

test("deriveProfileChannels does not pair names with ids by index", () => {
// `channelIds` arrives in channel-id order from relay membership while
// `channels` keeps whatever order the kind:10100 author wrote. Pairing by
// index here labelled a row "general" and opened #random.
const links = deriveProfileChannels(
"ff".repeat(32),
relayAgent({
channels: ["random", "general"],
channelIds: [CHANNEL_A, CHANNEL_B],
}),
undefined,
COMMUNITY_CHANNELS,
);

assert.deepEqual(links, [
{ id: CHANNEL_A, name: "general" },
{ id: CHANNEL_B, name: "random" },
]);
});

test("deriveProfileChannels surfaces a channel id the profile never named", () => {
// `buzz-acp` appends to `channel_ids` without naming the channel, so an id
// with no matching name still has to resolve.
const links = deriveProfileChannels(
"ff".repeat(32),
relayAgent({ channels: [], channelIds: [CHANNEL_B] }),
undefined,
COMMUNITY_CHANNELS,
);

assert.deepEqual(links, [{ id: CHANNEL_B, name: "random" }]);
});

test("deriveProfileChannels keeps a named channel the viewer cannot resolve", () => {
const links = deriveProfileChannels(
"ff".repeat(32),
relayAgent({ channels: ["private-room"], channelIds: [] }),
undefined,
COMMUNITY_CHANNELS,
);

assert.deepEqual(links, [{ id: "private-room", name: "private-room" }]);
});
25 changes: 21 additions & 4 deletions desktop/src/features/profile/ui/UserProfilePanelUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,28 @@ export function deriveProfileChannels(
const channelsByName = new Map(
channels?.map((channel) => [channel.name, channel]) ?? [],
);
const channelsById = new Map(
channels?.map((channel) => [channel.id, channel]) ?? [],
);

relayAgent?.channels.forEach((name, index) => {
const channel = channelsByName.get(name);
const id = relayAgent.channelIds[index] ?? channel?.id ?? name;
links.set(id, { id, name });
// `channels` (names) and `channelIds` are independent arrays, so they cannot
// be paired by index. The backend replaces `channelIds` with relay membership
// in channel-id order before this sees it, and `buzz-acp` appends to the
// profile's copy without touching the names — either one makes position N of
// one array a different channel from position N of the other. Pairing them
// anyway labelled a row with another channel's name and navigated to that
// other channel. Resolve each side against the community's channel list.
for (const id of relayAgent?.channelIds ?? []) {
const channel = channelsById.get(id);
if (channel) {
links.set(channel.id, { id: channel.id, name: channel.name });
}
}
relayAgent?.channels.forEach((name) => {
const id = channelsByName.get(name)?.id ?? name;
if (!links.has(id)) {
links.set(id, { id, name });
}
});

if (managedAgent && channels) {
Expand Down
Loading