From a332b5e53491fbaf94fc38632aacb5b544f09fc2 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Fri, 17 Jul 2026 21:39:40 -0400 Subject: [PATCH] fix(desktop): relay-scope the channel agent reuse finders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the cross-community reuse fix on the *creation* path. The attach guards (commit b49523e7f) stopped a foreign-relay agent from being silently attached, but the reuse finders that decide "reuse existing vs create new" were still relay-unfiltered: a persona/generic agent pinned to community A's relay would surface as a reuse candidate while Desktop viewed community B, so creating a B agent could return the deaf A agent instead of a fresh B one — user-visible wrongness (Eva's scope call). Relay-scope all three reuse finders with the existing `agentBelongsToRelay` helper: 1. `findReusablePersonaAgent` / `findReusableGenericAgent` gain a REQUIRED `activeRelayUrl` and filter candidates to the active community's relay before preference selection. A foreign-relay candidate is excluded, so the caller falls through to creating a fresh agent on the active relay. 2. `findReusableAgent` (the routing wrapper) threads `activeRelayUrl` through to both finders. 3. Both consumers pass it: `provisionChannelManagedAgent`'s context (via `createChannelManagedAgents(..., activeRelayUrl)` and the provision mutation hook) and the `useReusableAgentDetection` UI guardrail, each sourcing the relay from `useActiveRelayUrl()`. The param is REQUIRED so the compiler finds every caller and no future call can silently bypass the scope. `agentBelongsToRelay` stays permissive for blank pins / blank active relay, so existing tests and legacy blank-pin records are unaffected. Four unit tests cover the new behavior: a B-pinned persona/generic candidate is excluded when the active relay is A (fresh agent created, no error), a same-relay candidate is still reused, and the wrapper routes the foreign-relay persona case to a fresh agent. 42/42 agentReuse tests pass; tsc --noEmit and biome check clean. Stacked on wren/agents-every-community-successor (commit #4 on b49523e7f). Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc --- .../src/features/agents/agentReuse.test.mjs | 65 +++++++++++++++++++ desktop/src/features/agents/agentReuse.ts | 12 +++- desktop/src/features/agents/channelAgents.ts | 5 +- desktop/src/features/agents/hooks.ts | 2 + .../channels/ui/useReusableAgentDetection.ts | 29 ++++++--- 5 files changed, 102 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/agents/agentReuse.test.mjs b/desktop/src/features/agents/agentReuse.test.mjs index cc85a1de8..cf60c0d09 100644 --- a/desktop/src/features/agents/agentReuse.test.mjs +++ b/desktop/src/features/agents/agentReuse.test.mjs @@ -308,6 +308,71 @@ test("findReusableGenericAgent: command matching uses normalization", () => { assert.equal(result, agent); }); +test("findReusablePersonaAgent: excludes agent pinned to a different relay", () => { + const agent = makeAgent({ + personaId: "persona-1", + pubkey: PUB_A, + relayUrl: "wss://relay-a.example", + }); + const channelMembers = new Set([PUB_B]); + const result = findReusablePersonaAgent( + [agent], + "persona-1", + channelMembers, + "wss://relay-b.example", + ); + assert.equal(result, undefined); +}); + +test("findReusablePersonaAgent: reuses agent pinned to the active relay", () => { + const agent = makeAgent({ + personaId: "persona-1", + pubkey: PUB_A, + relayUrl: "wss://relay-b.example", + }); + const channelMembers = new Set([PUB_B]); + const result = findReusablePersonaAgent( + [agent], + "persona-1", + channelMembers, + "wss://relay-b.example", + ); + assert.equal(result, agent); +}); + +test("findReusableGenericAgent: excludes agent pinned to a different relay", () => { + const agent = makeAgent({ + agentCommand: "goose", + personaId: null, + systemPrompt: null, + relayUrl: "wss://relay-a.example", + }); + const channelMembers = new Set([PUB_B]); + const result = findReusableGenericAgent( + [agent], + "goose", + channelMembers, + "wss://relay-b.example", + ); + assert.equal(result, undefined); +}); + +test("findReusableAgent: foreign-relay persona candidate yields fresh agent (no reuse)", () => { + const agent = makeAgent({ + personaId: "p1", + pubkey: PUB_A, + relayUrl: "wss://relay-a.example", + }); + const channelMembers = new Set([PUB_B]); + const result = findReusableAgent( + [agent], + channelMembers, + { personaId: "p1", command: "goose" }, + "wss://relay-b.example", + ); + assert.equal(result, undefined); +}); + test("findReusableAgent: routes to persona search when personaId provided", () => { const agent = makeAgent({ personaId: "p1", pubkey: PUB_A }); const channelMembers = new Set([PUB_B]); diff --git a/desktop/src/features/agents/agentReuse.ts b/desktop/src/features/agents/agentReuse.ts index b0d800703..3a438d3d4 100644 --- a/desktop/src/features/agents/agentReuse.ts +++ b/desktop/src/features/agents/agentReuse.ts @@ -1,3 +1,4 @@ +import { agentBelongsToRelay } from "@/features/agents/agentRelayScope"; import type { ManagedAgent } from "@/shared/api/types"; /** Inline normalization — avoids runtime dependency on @/shared/lib/pubkey. */ @@ -50,11 +51,13 @@ export function findReusablePersonaAgent( agents: ManagedAgent[], personaId: string, channelMemberPubkeys: ReadonlySet, + activeRelayUrl: string | null | undefined, ): ManagedAgent | undefined { const candidates = agents.filter( (agent) => agent.personaId === personaId && - !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), + !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)) && + agentBelongsToRelay(agent.relayUrl, activeRelayUrl), ); return pickPreferredManagedAgent(candidates); } @@ -63,13 +66,15 @@ export function findReusableGenericAgent( agents: ManagedAgent[], command: string, channelMemberPubkeys: ReadonlySet, + activeRelayUrl: string | null | undefined, ): ManagedAgent | undefined { const candidates = agents.filter( (agent) => !agent.personaId && !agent.systemPrompt?.trim() && commandsMatch(agent.agentCommand, command) && - !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), + !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)) && + agentBelongsToRelay(agent.relayUrl, activeRelayUrl), ); return pickPreferredManagedAgent(candidates); } @@ -86,12 +91,14 @@ export function findReusableAgent( systemPrompt?: string; command: string; }, + activeRelayUrl: string | null | undefined, ): ManagedAgent | undefined { if (input.personaId) { return findReusablePersonaAgent( agents, input.personaId, channelMemberPubkeys, + activeRelayUrl, ); } if (!input.systemPrompt?.trim()) { @@ -99,6 +106,7 @@ export function findReusableAgent( agents, input.command, channelMemberPubkeys, + activeRelayUrl, ); } return undefined; diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 40ef189ee..132894d3e 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -299,6 +299,7 @@ export async function provisionChannelManagedAgent( context?: { managedAgents?: ManagedAgent[]; channelMemberPubkeys?: ReadonlySet; + activeRelayUrl?: string | null; }, ): Promise { const trimmedName = input.name.trim(); @@ -319,6 +320,7 @@ export async function provisionChannelManagedAgent( context.managedAgents, input.personaId, context.channelMemberPubkeys, + context.activeRelayUrl, ); if (reusable) { // Apply the caller's respondTo settings so the user's permission @@ -359,6 +361,7 @@ export async function provisionChannelManagedAgent( context.managedAgents, input.runtime.command, context.channelMemberPubkeys, + context.activeRelayUrl, ); if (reusable) { const needsRespondToUpdate = @@ -464,7 +467,7 @@ export async function createChannelManagedAgents( const channelMemberPubkeys = new Set( members.map((m) => normalizePubkey(m.pubkey)), ); - const context = { managedAgents, channelMemberPubkeys }; + const context = { managedAgents, channelMemberPubkeys, activeRelayUrl }; // Sequential loop: each agent must be fully created and its relay membership // written before the next starts. Concurrent writes to the replaceable diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 6bf7a102c..852172f9e 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -711,6 +711,7 @@ export function useProvisionChannelManagedAgentMutation( channelId: string | null, ) { const queryClient = useQueryClient(); + const activeRelayUrl = useActiveRelayUrl(); return useMutation({ mutationFn: async ( @@ -731,6 +732,7 @@ export function useProvisionChannelManagedAgentMutation( channelMemberPubkeys: new Set( members.map((member) => normalizePubkey(member.pubkey)), ), + activeRelayUrl, }); }, onSuccess: (result) => { diff --git a/desktop/src/features/channels/ui/useReusableAgentDetection.ts b/desktop/src/features/channels/ui/useReusableAgentDetection.ts index c35286a4a..f1719064b 100644 --- a/desktop/src/features/channels/ui/useReusableAgentDetection.ts +++ b/desktop/src/features/channels/ui/useReusableAgentDetection.ts @@ -5,6 +5,7 @@ import { useManagedAgentsQuery, } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; @@ -25,6 +26,7 @@ export function useReusableAgentDetection( ): ManagedAgent | undefined { const managedAgentsQuery = useManagedAgentsQuery(); const channelMembersQuery = useChannelMembersQuery(channelId, enabled); + const activeRelayUrl = useActiveRelayUrl(); return React.useMemo(() => { const agents = managedAgentsQuery.data; @@ -36,10 +38,15 @@ export function useReusableAgentDetection( // For persona selection: check the first selected persona if (selectedPersonas.length === 1 && !includeGeneric) { - return findReusableAgent(agents, memberPubkeys, { - personaId: selectedPersonas[0].id, - command: selectedRuntime.command, - }); + return findReusableAgent( + agents, + memberPubkeys, + { + personaId: selectedPersonas[0].id, + command: selectedRuntime.command, + }, + activeRelayUrl, + ); } // For generic agent with no custom prompt @@ -48,10 +55,15 @@ export function useReusableAgentDetection( selectedPersonas.length === 0 && !customPrompt.trim() ) { - return findReusableAgent(agents, memberPubkeys, { - command: selectedRuntime.command, - systemPrompt: customPrompt, - }); + return findReusableAgent( + agents, + memberPubkeys, + { + command: selectedRuntime.command, + systemPrompt: customPrompt, + }, + activeRelayUrl, + ); } return undefined; @@ -62,5 +74,6 @@ export function useReusableAgentDetection( selectedPersonas, includeGeneric, customPrompt, + activeRelayUrl, ]); }