From ba737f9d2ee4d188cd249113175c9f29c72bf14a Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 16:23:46 -0700 Subject: [PATCH 1/5] fix(desktop): prefer live agent mentions Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../lib/agentAutocompleteEligibility.test.mjs | 78 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 48 +++++++++++- .../src/features/messages/lib/useMentions.ts | 46 +++++------ 3 files changed, 146 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index f04d722e7..ea21e8792 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, + getPreferredAgentPubkeys, getSharedChannelIds, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, @@ -135,6 +136,24 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); +test("getPreferredAgentPubkeys: includes only active managed and reachable relay agents", () => { + assert.deepEqual( + getPreferredAgentPubkeys({ + managedAgents: [ + { pubkey: PUB_A, status: "running" }, + { pubkey: PUB_B, status: "deployed" }, + { pubkey: PUB_C, status: "stopped" }, + { pubkey: PUB_D, status: "not_deployed" }, + ], + relayAgents: [ + { pubkey: PUB_C, status: "online" }, + { pubkey: PUB_D, status: "offline" }, + ], + }), + new Set([PUB_A, PUB_B, PUB_C]), + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ @@ -227,6 +246,65 @@ test("coalesceAgentAutocompleteCandidates: merges agents with the same persona i assert.deepEqual(coalesce([first, second]), [second]); }); +test("coalesceAgentAutocompleteCandidates: keeps a persona fallback beside stopped instances", () => { + const stoppedInstance = makeAgent({ + pubkey: PUB_A, + personaId: "pinky", + isManagedAgent: true, + kind: "identity", + }); + const personaFallback = makeAgent({ + pubkey: undefined, + personaId: "pinky", + kind: "persona", + }); + + assert.deepEqual(coalesce([stoppedInstance, personaFallback]), [ + stoppedInstance, + personaFallback, + ]); +}); + +test("coalesceAgentAutocompleteCandidates: prefers a runnable replacement over a stale member", () => { + const staleMember = makeAgent({ + pubkey: PUB_A, + personaId: "pinky", + isMember: true, + }); + const runnableReplacement = makeAgent({ + pubkey: PUB_B, + personaId: "pinky", + isManagedAgent: true, + }); + + assert.deepEqual( + coalesce([staleMember, runnableReplacement], { + preferredPubkeys: new Set([PUB_B]), + }), + [runnableReplacement], + ); +}); + +test("coalesceAgentAutocompleteCandidates: keeps an invocable member for the logical agent", () => { + const member = makeAgent({ + pubkey: PUB_A, + personaId: "pinky", + isMember: true, + }); + const otherInstance = makeAgent({ + pubkey: PUB_B, + personaId: "pinky", + isManagedAgent: true, + }); + + assert.deepEqual( + coalesce([member, otherInstance], { + preferredPubkeys: new Set([PUB_A, PUB_B]), + }), + [member], + ); +}); + test("coalesceAgentAutocompleteCandidates: merges agents with the same owner and name", () => { const first = makeAgent({ pubkey: PUB_A, ownerPubkey: OWNER_PUBKEY }); const second = makeAgent({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 51d840b9f..ecee9ad52 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,4 +1,4 @@ -import type { Channel, RelayAgent } from "@/shared/api/types"; +import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; export function getSharedChannelIds(channels: readonly Channel[] | undefined) { @@ -54,6 +54,45 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +export function isActiveManagedAgent(agent: Pick) { + return agent.status === "running" || agent.status === "deployed"; +} + +export function getActiveManagedAgentMetadata( + agents: + | readonly Pick[] + | undefined, +) { + const activeAgents = (agents ?? []).filter(isActiveManagedAgent); + return { + personaIds: new Set( + activeAgents + .map((agent) => agent.personaId) + .filter((personaId): personaId is string => Boolean(personaId)), + ), + pubkeys: new Set( + activeAgents.map((agent) => normalizePubkey(agent.pubkey)), + ), + }; +} + +export function getPreferredAgentPubkeys({ + managedAgents, + relayAgents, +}: { + managedAgents: readonly Pick[] | undefined; + relayAgents: readonly Pick[] | undefined; +}) { + return new Set([ + ...(managedAgents ?? []) + .filter(isActiveManagedAgent) + .map((agent) => normalizePubkey(agent.pubkey)), + ...(relayAgents ?? []) + .filter((agent) => agent.status !== "offline") + .map((agent) => normalizePubkey(agent.pubkey)), + ]); +} + export function shouldHideAgentFromMentions({ isAgent, isMember, @@ -94,6 +133,7 @@ type AgentAutocompleteCandidate = { isAgent?: boolean; isManagedAgent?: boolean; isMember?: boolean; + kind?: "identity" | "persona" | "team"; personaId?: string | null; }; @@ -111,7 +151,9 @@ function agentIdentityKey( } if (candidate.personaId) { - return `persona:${candidate.personaId}`; + return candidate.kind === "persona" + ? `persona-template:${candidate.personaId}` + : `persona:${candidate.personaId}`; } const label = normalizeLabel(getLabel(candidate)); @@ -146,8 +188,8 @@ function agentCandidateRank( : null; return [ - candidate.isMember === true ? 0 : 1, pubkey && preferredPubkeys.has(pubkey) ? 0 : 1, + candidate.isMember === true ? 0 : 1, candidate.isManagedAgent === true ? 0 : 1, candidate.personaId ? 0 : 1, ownerPubkey && ownerPubkey === normalizedCurrentPubkey ? 0 : 1, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index fe0fca99d..60a3dcc9a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -14,7 +14,9 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { coalesceAgentAutocompleteCandidates, coalesceAutocompleteCandidatesByKey, + getActiveManagedAgentMetadata, getMentionableAgentPubkeys, + getPreferredAgentPubkeys, getSharedChannelIds, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; @@ -150,24 +152,12 @@ export function useMentions( ), [managedAgentsQuery.data], ); - const managedAgentPersonaIds = React.useMemo( - () => - new Set( - (managedAgentsQuery.data ?? []) - .map((agent) => agent.personaId) - .filter((personaId): personaId is string => Boolean(personaId)), - ), - [managedAgentsQuery.data], - ); - const managedAgentPubkeys = React.useMemo( - () => - new Set( - (managedAgentsQuery.data ?? []).map((agent) => - normalizePubkey(agent.pubkey), - ), - ), + const activeManagedAgentMetadata = React.useMemo( + () => getActiveManagedAgentMetadata(managedAgentsQuery.data), [managedAgentsQuery.data], ); + const managedAgentPersonaIds = activeManagedAgentMetadata.personaIds; + const managedAgentPubkeys = activeManagedAgentMetadata.pubkeys; const relayAgentNamesByPubkey = React.useMemo( () => new Map( @@ -237,6 +227,14 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); + const preferredAgentPubkeys = React.useMemo( + () => + getPreferredAgentPubkeys({ + managedAgents: managedAgentsQuery.data, + relayAgents: relayAgentsQuery.data, + }), + [managedAgentsQuery.data, relayAgentsQuery.data], + ); const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); @@ -345,9 +343,14 @@ export function useMentions( } for (const agent of managedAgentsQuery.data ?? []) { + const pubkey = normalizePubkey(agent.pubkey); + if (!preferredAgentPubkeys.has(pubkey)) { + continue; + } + addCandidate({ kind: "identity", - pubkey: agent.pubkey, + pubkey, displayName: agent.name, isMember: false, isAgent: true, @@ -404,7 +407,7 @@ export function useMentions( { currentPubkey, getLabel: mentionCandidateLabel, - preferredPubkeys: memberPubkeys, + preferredPubkeys: preferredAgentPubkeys, }, ); }, [ @@ -419,10 +422,10 @@ export function useMentions( managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, managedAgentsQuery.data, - memberPubkeys, members, mentionableAgentPubkeys, personaNameByPubkey, + preferredAgentPubkeys, profiles, relayAgentNamesByPubkey, relayAgentsQuery.data, @@ -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, From 93b93ac661857e2c103876a2b1e76a2ab9139c73 Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 17:33:20 -0700 Subject: [PATCH 2/5] fix(desktop): restrict agent mentions to managed list Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../lib/agentAutocompleteEligibility.test.mjs | 99 +++++-------------- .../lib/agentAutocompleteEligibility.ts | 52 ++-------- .../src/features/messages/lib/useMentions.ts | 48 ++++----- 3 files changed, 58 insertions(+), 141 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index ea21e8792..4e02b7bd6 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -4,8 +4,8 @@ import test from "node:test"; import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, - getPreferredAgentPubkeys, getSharedChannelIds, + isAgentIdentityInManagedList, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -136,21 +136,29 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("getPreferredAgentPubkeys: includes only active managed and reachable relay agents", () => { - assert.deepEqual( - getPreferredAgentPubkeys({ - managedAgents: [ - { pubkey: PUB_A, status: "running" }, - { pubkey: PUB_B, status: "deployed" }, - { pubkey: PUB_C, status: "stopped" }, - { pubkey: PUB_D, status: "not_deployed" }, - ], - relayAgents: [ - { pubkey: PUB_C, status: "online" }, - { pubkey: PUB_D, status: "offline" }, - ], - }), - 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, ); }); @@ -246,65 +254,6 @@ test("coalesceAgentAutocompleteCandidates: merges agents with the same persona i assert.deepEqual(coalesce([first, second]), [second]); }); -test("coalesceAgentAutocompleteCandidates: keeps a persona fallback beside stopped instances", () => { - const stoppedInstance = makeAgent({ - pubkey: PUB_A, - personaId: "pinky", - isManagedAgent: true, - kind: "identity", - }); - const personaFallback = makeAgent({ - pubkey: undefined, - personaId: "pinky", - kind: "persona", - }); - - assert.deepEqual(coalesce([stoppedInstance, personaFallback]), [ - stoppedInstance, - personaFallback, - ]); -}); - -test("coalesceAgentAutocompleteCandidates: prefers a runnable replacement over a stale member", () => { - const staleMember = makeAgent({ - pubkey: PUB_A, - personaId: "pinky", - isMember: true, - }); - const runnableReplacement = makeAgent({ - pubkey: PUB_B, - personaId: "pinky", - isManagedAgent: true, - }); - - assert.deepEqual( - coalesce([staleMember, runnableReplacement], { - preferredPubkeys: new Set([PUB_B]), - }), - [runnableReplacement], - ); -}); - -test("coalesceAgentAutocompleteCandidates: keeps an invocable member for the logical agent", () => { - const member = makeAgent({ - pubkey: PUB_A, - personaId: "pinky", - isMember: true, - }); - const otherInstance = makeAgent({ - pubkey: PUB_B, - personaId: "pinky", - isManagedAgent: true, - }); - - assert.deepEqual( - coalesce([member, otherInstance], { - preferredPubkeys: new Set([PUB_A, PUB_B]), - }), - [member], - ); -}); - test("coalesceAgentAutocompleteCandidates: merges agents with the same owner and name", () => { const first = makeAgent({ pubkey: PUB_A, ownerPubkey: OWNER_PUBKEY }); const second = makeAgent({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index ecee9ad52..e4afe7fea 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,4 +1,4 @@ -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; export function getSharedChannelIds(channels: readonly Channel[] | undefined) { @@ -54,43 +54,14 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isActiveManagedAgent(agent: Pick) { - return agent.status === "running" || agent.status === "deployed"; -} - -export function getActiveManagedAgentMetadata( - agents: - | readonly Pick[] - | undefined, +export function isAgentIdentityInManagedList( + candidate: { isAgent?: boolean; pubkey: string }, + managedAgentPubkeys: ReadonlySet, ) { - const activeAgents = (agents ?? []).filter(isActiveManagedAgent); - return { - personaIds: new Set( - activeAgents - .map((agent) => agent.personaId) - .filter((personaId): personaId is string => Boolean(personaId)), - ), - pubkeys: new Set( - activeAgents.map((agent) => normalizePubkey(agent.pubkey)), - ), - }; -} - -export function getPreferredAgentPubkeys({ - managedAgents, - relayAgents, -}: { - managedAgents: readonly Pick[] | undefined; - relayAgents: readonly Pick[] | undefined; -}) { - return new Set([ - ...(managedAgents ?? []) - .filter(isActiveManagedAgent) - .map((agent) => normalizePubkey(agent.pubkey)), - ...(relayAgents ?? []) - .filter((agent) => agent.status !== "offline") - .map((agent) => normalizePubkey(agent.pubkey)), - ]); + return ( + candidate.isAgent !== true || + managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + ); } export function shouldHideAgentFromMentions({ @@ -133,7 +104,6 @@ type AgentAutocompleteCandidate = { isAgent?: boolean; isManagedAgent?: boolean; isMember?: boolean; - kind?: "identity" | "persona" | "team"; personaId?: string | null; }; @@ -151,9 +121,7 @@ function agentIdentityKey( } if (candidate.personaId) { - return candidate.kind === "persona" - ? `persona-template:${candidate.personaId}` - : `persona:${candidate.personaId}`; + return `persona:${candidate.personaId}`; } const label = normalizeLabel(getLabel(candidate)); @@ -188,8 +156,8 @@ function agentCandidateRank( : null; return [ - pubkey && preferredPubkeys.has(pubkey) ? 0 : 1, candidate.isMember === true ? 0 : 1, + pubkey && preferredPubkeys.has(pubkey) ? 0 : 1, candidate.isManagedAgent === true ? 0 : 1, candidate.personaId ? 0 : 1, ownerPubkey && ownerPubkey === normalizedCurrentPubkey ? 0 : 1, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 60a3dcc9a..0c73b7533 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -14,10 +14,9 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { coalesceAgentAutocompleteCandidates, coalesceAutocompleteCandidatesByKey, - getActiveManagedAgentMetadata, getMentionableAgentPubkeys, - getPreferredAgentPubkeys, getSharedChannelIds, + isAgentIdentityInManagedList, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -152,12 +151,24 @@ export function useMentions( ), [managedAgentsQuery.data], ); - const activeManagedAgentMetadata = React.useMemo( - () => getActiveManagedAgentMetadata(managedAgentsQuery.data), + const managedAgentPersonaIds = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []) + .map((agent) => agent.personaId) + .filter((personaId): personaId is string => Boolean(personaId)), + ), + [managedAgentsQuery.data], + ); + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), [managedAgentsQuery.data], ); - const managedAgentPersonaIds = activeManagedAgentMetadata.personaIds; - const managedAgentPubkeys = activeManagedAgentMetadata.pubkeys; const relayAgentNamesByPubkey = React.useMemo( () => new Map( @@ -227,14 +238,6 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); - const preferredAgentPubkeys = React.useMemo( - () => - getPreferredAgentPubkeys({ - managedAgents: managedAgentsQuery.data, - relayAgents: relayAgentsQuery.data, - }), - [managedAgentsQuery.data, relayAgentsQuery.data], - ); const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); @@ -243,6 +246,9 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } + if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + return; + } if ( shouldHideAgentFromMentions({ isAgent: candidate.isAgent === true, @@ -254,7 +260,6 @@ export function useMentions( ) { return; } - const current = candidatesByPubkey.get(pubkey); if (!current) { candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); @@ -287,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) @@ -343,14 +347,9 @@ export function useMentions( } for (const agent of managedAgentsQuery.data ?? []) { - const pubkey = normalizePubkey(agent.pubkey); - if (!preferredAgentPubkeys.has(pubkey)) { - continue; - } - addCandidate({ kind: "identity", - pubkey, + pubkey: agent.pubkey, displayName: agent.name, isMember: false, isAgent: true, @@ -407,7 +406,7 @@ export function useMentions( { currentPubkey, getLabel: mentionCandidateLabel, - preferredPubkeys: preferredAgentPubkeys, + preferredPubkeys: memberPubkeys, }, ); }, [ @@ -421,11 +420,12 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, + managedAgentPubkeys, managedAgentsQuery.data, + memberPubkeys, members, mentionableAgentPubkeys, personaNameByPubkey, - preferredAgentPubkeys, profiles, relayAgentNamesByPubkey, relayAgentsQuery.data, From 632d4b40ed05495912ef612153e52534e59ddb65 Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 19:30:33 -0700 Subject: [PATCH 3/5] fix(desktop): align agent add search Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../features/channels/ui/MembersSidebar.tsx | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index f5fdf6f3f..4fa3a575a 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -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, From 60352ea36b39c3e7f6865ef6d3e1d0121431304b Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 20:42:54 -0700 Subject: [PATCH 4/5] test(desktop): align managed agent fixtures Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- desktop/tests/e2e/channels.spec.ts | 39 +++++++++++++++++++----- desktop/tests/e2e/mentions.spec.ts | 49 ++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 8f50a6ab1..dce8dd2fa 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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 diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 8a982a6da..1ff16098b 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -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); }); @@ -759,10 +763,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 +795,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 +804,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 +1375,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 +1470,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"); From 2847fa68ebee73f7a311220e062d75eb5cdbcb1c Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 21:06:01 -0700 Subject: [PATCH 5/5] test(desktop): update remaining agent search cases Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../tests/e2e/identity-archive-hide.spec.ts | 20 ++++---- desktop/tests/e2e/mentions.spec.ts | 47 +++++++++++++++---- desktop/tests/e2e/thread-focus-mode.spec.ts | 11 ++++- desktop/tests/e2e/virtualization.spec.ts | 11 ++++- 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/desktop/tests/e2e/identity-archive-hide.spec.ts b/desktop/tests/e2e/identity-archive-hide.spec.ts index b943568ab..39410e263 100644 --- a/desktop/tests/e2e/identity-archive-hide.spec.ts +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -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(); }); }); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 1ff16098b..694b5abef 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -429,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"); @@ -466,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) => ({ @@ -483,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( @@ -497,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 }), }), @@ -525,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"); @@ -695,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); diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 75b817a8a..6b4dde6f3 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -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); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index a83478028..31800ae7e 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -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");