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, ]); }