fix(desktop): prefer live agent mentions (#2149)

Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-20 08:58:48 -07:00
committed by GitHub
co-authored by npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
parent 9fb1a90b66
commit a4dfead21a
9 changed files with 187 additions and 64 deletions
@@ -5,6 +5,7 @@ import {
coalesceAgentAutocompleteCandidates,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
} from "./agentAutocompleteEligibility.ts";
@@ -135,6 +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", () => {
const managedAgentPubkeys = new Set([PUB_A]);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: false, pubkey: PUB_B },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
),
false,
);
});
test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
@@ -54,6 +54,16 @@ export function getMentionableAgentPubkeys({
return pubkeys;
}
export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
}
export function shouldHideAgentFromMentions({
isAgent,
isMember,
@@ -4,12 +4,10 @@ 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";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
@@ -146,7 +144,6 @@ export function MembersSidebar({
const identityQuery = useIdentityQuery();
const membersQuery = useChannelMembersQuery(channelId, open);
const addMembersMutation = useAddChannelMembersMutation(channelId);
const channelsQuery = useChannelsQuery({ enabled: open });
const changeRoleMutation = useMutation({
mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => {
if (!channelId) throw new Error("No channel selected.");
@@ -262,15 +259,7 @@ export function MembersSidebar({
.map((member) => member.displayName?.trim().toLowerCase())
.filter((label): label is string => Boolean(label)),
);
const sharedChannelIds = getSharedChannelIds(channelsQuery.data);
const eligibleAgentPubkeys = getMentionableAgentPubkeys({
currentPubkey,
managedAgentPubkeys: (managedAgentsQuery.data ?? []).map(
(agent) => agent.pubkey,
),
relayAgents: relayAgentsQuery.data,
sharedChannelIds,
});
const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys());
const addCandidate = (candidate: AddMemberSearchCandidate) => {
const pubkey = normalizePubkey(candidate.pubkey);
@@ -281,7 +270,7 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
(candidate.isAgent && !eligibleAgentPubkeys.has(pubkey))
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
) {
return;
}
@@ -320,10 +309,6 @@ export function MembersSidebar({
}
for (const agent of relayAgentsQuery.data ?? []) {
if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) {
continue;
}
addCandidate({
pubkey: agent.pubkey,
displayName: agent.name,
@@ -365,7 +350,6 @@ export function MembersSidebar({
}, [
canAddMembers,
isArchivedDiscovery,
channelsQuery.data,
currentPubkey,
managedAgentsQuery.data,
memberPubkeys,
@@ -377,8 +361,7 @@ export function MembersSidebar({
const isAddSearchLoading =
userSearchQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading ||
channelsQuery.isLoading;
relayAgentsQuery.isLoading;
const handlePeopleSearchScroll = useUserSearchFetchMoreOnScroll(
userSearchQuery,
canAddMembers && normalizedDeferredSearchQuery.length > 0,
@@ -16,6 +16,7 @@ import {
coalesceAutocompleteCandidatesByKey,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
shouldHideAgentFromMentions,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import {
@@ -245,6 +246,9 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
return;
}
if (
shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
@@ -256,7 +260,6 @@ export function useMentions(
) {
return;
}
const current = candidatesByPubkey.get(pubkey);
if (!current) {
candidatesByPubkey.set(pubkey, { ...candidate, pubkey });
@@ -289,7 +292,6 @@ export function useMentions(
isManagedAgent: current.isManagedAgent || candidate.isManagedAgent,
});
};
for (const member of members ?? []) {
const pubkey = normalizePubkey(member.pubkey);
const linkedPersonaId = activePersonaById.has(pubkey)
@@ -418,6 +420,7 @@ export function useMentions(
managedAgentNamesByPubkey,
managedAgentPersonaIds,
managedAgentPersonaIdsByPubkey,
managedAgentPubkeys,
managedAgentsQuery.data,
memberPubkeys,
members,
@@ -540,10 +543,7 @@ export function useMentions(
mentionQuery,
activePersonaIds,
)
.slice(
0,
Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidatesWithTeams.length),
)
.slice(0, MENTION_SUGGESTION_LIMIT)
.map(({ candidate, label }) =>
mapMentionCandidateToSuggestion({
candidate,
+32 -7
View File
@@ -812,10 +812,17 @@ test("routes an agent mention from an existing DM to the expanded conversation",
);
});
test("routes a relay-agent mention from an existing DM to the expanded conversation", async ({
test("routes a managed relay-agent mention from an existing DM to the expanded conversation", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
name: "quinn",
status: "stopped",
},
],
relayAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
@@ -856,15 +863,13 @@ test("routes a relay-agent mention from an existing DM to the expanded conversat
page.locator("[data-active='true'][data-channel-id]"),
).toHaveAttribute("data-channel-id", sentChannelId ?? "");
await expect(page.getByTestId("chat-title")).toContainText("alice");
await expect(page.getByTestId("chat-title")).toContainText(
DM_RELAY_AGENT_PUBKEY.slice(0, 8),
);
await expect(page.getByTestId("chat-title")).toContainText("quinn");
const sendCommands = (await readCommandPayloadLog(page)).slice(
baselineCommands.length,
);
expect(sendCommands.map((entry) => entry.command)).toContain("open_dm");
expect(sendCommands.map((entry) => entry.command)).not.toContain(
expect(sendCommands.map((entry) => entry.command)).toContain(
"start_managed_agent",
);
expect(sendCommands.map((entry) => entry.command)).not.toContain(
@@ -2554,7 +2559,18 @@ test("home inbox manage affordance opens management without leaving home", async
await expect(page).not.toHaveURL(/#\/channels\//);
});
test("members sidebar can invite and remove members", async ({ page }) => {
test("members sidebar can invite and remove managed agents", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await openMembersSidebar(page, "general");
const initialMemberCount = await readMembersTriggerCount(page);
@@ -2847,9 +2863,18 @@ test("members sidebar collapses same-persona managed agents", async ({
await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1);
});
test("private-channel members can add members and bots without admin", async ({
test("private-channel members can add people and managed agents without admin", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
// secret-projects is a private (non-DM) channel where the current user is a
// plain member, not owner/admin. They should still be able to add members
+10 -10
View File
@@ -166,38 +166,38 @@ test.describe("NIP-IA hide archived from discovery", () => {
});
// Member-adder (ChannelMemberInviteCard) — the invite search excludes
// existing channel members, so we test in #agents where Alice is NOT a
// existing channel members, so we test in #agents where Bob is NOT a
// member (only the mock identity + Charlie are): the ONLY thing that can
// drop her from results is the archive filter. The control run (nothing
// archived) proves she would otherwise appear — guarding a vacuous green.
// drop Bob from results is the archive filter. The control run (nothing
// archived) proves he would otherwise appear — guarding a vacuous green.
test("member-adder: archived non-member is omitted from invite search", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await installMockBridge(page, { archivedIdentities: [BOB_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("alice");
await page.getByTestId("channel-management-search-users").fill("bob");
await expect(
page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`),
page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`),
).toHaveCount(0);
});
test("member-adder: control — non-archived non-member IS in invite search", async ({
page,
}) => {
// Same channel/query, nothing archived: Alice now appears. This is the
// companion that makes the test above non-vacuous (proves she was dropped
// Same channel/query, nothing archived: Bob now appears. This is the
// companion that makes the test above non-vacuous (proves he was dropped
// by the archive filter, not by member-exclusion or a broken search).
await installMockBridge(page, { archivedIdentities: [] });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("alice");
await page.getByTestId("channel-management-search-users").fill("bob");
await expect(
page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`),
page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`),
).toBeVisible();
});
});
+78 -18
View File
@@ -187,11 +187,18 @@ async function expectAgentProfileActionsHidden(
).toHaveCount(0);
}
test("@ trigger prioritizes channel members before runnable personas and other agents", async ({
test("@ trigger prioritizes channel members before runnable personas and other managed agents", async ({
page,
}) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -202,7 +209,7 @@ test("@ trigger prioritizes channel members before runnable personas and other a
const dropdown = autocomplete(page);
await expect(dropdown).toBeVisible();
await expect(dropdown.getByText("alice")).toBeVisible();
await expect(dropdown.getByText("alice")).toHaveCount(0);
await expect(dropdown.getByText("bob")).toBeVisible();
await expect(dropdown.getByText("Fizz")).toBeVisible();
await expect(dropdown.getByText("charlie")).toBeVisible();
@@ -219,7 +226,6 @@ test("@ trigger prioritizes channel members before runnable personas and other a
const suggestions = dropdown.locator("button");
const suggestionText = await suggestions.allInnerTexts();
const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz"));
const aliceIndex = suggestionText.findIndex((text) => text.includes("alice"));
const bobIndex = suggestionText.findIndex((text) => text.includes("bob"));
const charlieIndex = suggestionText.findIndex((text) =>
text.includes("charlie"),
@@ -228,11 +234,9 @@ test("@ trigger prioritizes channel members before runnable personas and other a
text.includes("outsider"),
);
expect(fizzIndex).toBeGreaterThanOrEqual(0);
expect(aliceIndex).toBeGreaterThanOrEqual(0);
expect(bobIndex).toBeGreaterThanOrEqual(0);
expect(charlieIndex).toBeGreaterThanOrEqual(0);
expect(outsiderIndex).toEqual(-1);
expect(aliceIndex).toBeLessThan(fizzIndex);
expect(bobIndex).toBeLessThan(fizzIndex);
expect(fizzIndex).toBeLessThan(charlieIndex);
});
@@ -425,7 +429,18 @@ test("defers agent mentions until DM members finish loading", async ({
await expect(threadPanel).toContainText("before members resolve");
});
test("autocomplete filters suggestions as user types", async ({ page }) => {
test("autocomplete filters managed-agent suggestions as user types", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.alice.pubkey,
name: "alice",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -462,7 +477,7 @@ test("autocomplete searches global non-member people from the first typed charac
await expect(tessaRow.getByText("not in channel")).toBeVisible();
});
test("mention autocomplete pages global people search beyond the first 50 results", async ({
test("mention autocomplete caps global people search at 50 results", async ({
page,
}) => {
const searchProfiles = Array.from({ length: 55 }, (_, index) => ({
@@ -479,10 +494,8 @@ test("mention autocomplete pages global people search beyond the first 50 result
const dropdown = autocomplete(page);
await expect(dropdown.locator("button")).toHaveCount(50);
await dropdown.evaluate((node) => node.scrollTo(0, node.scrollHeight));
await expect(dropdown.locator("button")).toHaveCount(55);
await expect(dropdown.getByText("Alex 55")).toBeVisible();
await expect(dropdown.getByText("Alex 50")).toBeVisible();
await expect(dropdown.getByText("Alex 55")).toHaveCount(0);
await expect(dropdown.getByText("not in channel").last()).toBeVisible();
const searchCalls = (await readCommandPayloadLog(page)).filter(
@@ -493,6 +506,10 @@ test("mention autocomplete pages global people search beyond the first 50 result
expect.objectContaining({
payload: expect.objectContaining({ cursor: null, limit: 50 }),
}),
]),
);
expect(searchCalls).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({ cursor: "2", limit: 50 }),
}),
@@ -521,9 +538,18 @@ test("selecting a person mention inserts @Name into input", async ({
await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/);
});
test("selecting an agent mention inserts @Name into input", async ({
test("selecting a managed agent mention inserts @Name into input", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.alice.pubkey,
name: "alice",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -691,9 +717,18 @@ test("selecting a persona mention reuses an existing persona agent", async ({
await expect(mentionChip).toHaveText("Fizz");
});
test("relay-profile agents with member roles use the agent composer style", async ({
test("managed relay-profile agents with member roles use the agent composer style", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await openChannelBrowser(page);
@@ -759,10 +794,17 @@ test("own profile-only agents are hidden from channel mentions", async ({
await expect(autocomplete(page)).toHaveCount(0);
});
test("allowlisted relay agents are visible in channel mentions", async ({
test("managed relay agents are visible in channel mentions regardless of relay policy", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
status: "stopped",
},
],
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
@@ -784,7 +826,7 @@ test("allowlisted relay agents are visible in channel mentions", async ({
await expect(dropdown.getByText("agent")).toBeVisible();
});
test("non-allowlisted relay agents stay hidden from channel mentions", async ({
test("relay-only agents stay hidden from channel mentions even when allowlisted", async ({
page,
}) => {
await installMockBridge(page, {
@@ -793,7 +835,7 @@ test("non-allowlisted relay agents stay hidden from channel mentions", async ({
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey],
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
},
],
});
@@ -1364,9 +1406,18 @@ test("system member-joined rows render the joined person as a plain profile name
await expect(joinedPersonName).not.toHaveAttribute("data-mention");
});
test("selecting a non-member agent from a DM inserts @Name into input", async ({
test("selecting a managed non-member agent from a DM inserts @Name into input", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-bob-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
@@ -1450,9 +1501,18 @@ test("sent non-member person mention uses the normal mention style", async ({
await expect(mentionChip.locator("svg")).toHaveCount(0);
});
test("sent non-member agent mention uses the agent mention style", async ({
test("sent managed non-member agent mention uses the agent mention style", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-bob-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
+10 -1
View File
@@ -143,7 +143,16 @@ test("focus and split preserve reading context and interaction ownership", async
await page.addInitScript(() => {
localStorage.setItem("buzz.channels.threadViewMode", "focus");
});
await installMockBridge(page);
await installMockBridge(page, {
managedAgents: [
{
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
name: "alice",
status: "stopped",
},
],
});
await page.goto("/");
const rootId = await seedLongThread(page);
+10 -1
View File
@@ -110,7 +110,16 @@ test.describe("list virtualization", () => {
test("03 — members search shows both sticky titles under content-visibility", async ({
page,
}) => {
await installMockBridge(page);
await installMockBridge(page, {
managedAgents: [
{
pubkey:
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");