fix(desktop): relay-scope the channel agent reuse finders

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 <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-07-17 21:39:40 -04:00
parent b49523e7f7
commit a332b5e534
5 changed files with 102 additions and 11 deletions
@@ -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]);
+10 -2
View File
@@ -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<string>,
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<string>,
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;
+4 -1
View File
@@ -299,6 +299,7 @@ export async function provisionChannelManagedAgent(
context?: {
managedAgents?: ManagedAgent[];
channelMemberPubkeys?: ReadonlySet<string>;
activeRelayUrl?: string | null;
},
): Promise<ProvisionChannelManagedAgentResult> {
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
+2
View File
@@ -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) => {
@@ -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,
]);
}