From 28e74bbf3a24ba9e58b3662a8f4793f6f362e3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Balzer?= Date: Mon, 7 Sep 2026 21:26:41 -0300 Subject: [PATCH] fix(desktop): reuse team agent identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make team deployment idempotent for persona-backed agents already attached to a channel and refresh batch reuse context after every successful attach. Co-authored-by: João Vitor Balzer Signed-off-by: João Vitor Balzer --- .../src/features/agents/agentReuse.test.mjs | 21 ++ desktop/src/features/agents/agentReuse.ts | 13 ++ .../agents/channelAgents.teamReuse.test.mjs | 199 ++++++++++++++++++ desktop/src/features/agents/channelAgents.ts | 51 ++++- .../agents/ui/AddTeamToChannelDialog.tsx | 2 - 5 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/agents/channelAgents.teamReuse.test.mjs diff --git a/desktop/src/features/agents/agentReuse.test.mjs b/desktop/src/features/agents/agentReuse.test.mjs index 4a33f8c0fcc..6cc5531dd42 100644 --- a/desktop/src/features/agents/agentReuse.test.mjs +++ b/desktop/src/features/agents/agentReuse.test.mjs @@ -5,6 +5,7 @@ import { commandsMatch, parseTimestamp, pickPreferredManagedAgent, + findPersonaAgentInChannel, findReusablePersonaAgent, findReusableGenericAgent, findReusableAgent, @@ -178,6 +179,26 @@ test("findReusablePersonaAgent: excludes agent already in channel", () => { assert.equal(result, undefined); }); +test("findPersonaAgentInChannel: finds the matching persona already attached", () => { + const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A }); + const result = findPersonaAgentInChannel( + [agent], + "persona-1", + new Set([PUB_A]), + ); + assert.equal(result, agent); +}); + +test("findPersonaAgentInChannel: ignores another persona in the channel", () => { + const agent = makeAgent({ personaId: "persona-2", pubkey: PUB_A }); + const result = findPersonaAgentInChannel( + [agent], + "persona-1", + new Set([PUB_A]), + ); + assert.equal(result, undefined); +}); + test("findReusablePersonaAgent: excludes agent with different personaId", () => { const agent = makeAgent({ personaId: "persona-2", 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 23597bf5b52..9a94c7b85ce 100644 --- a/desktop/src/features/agents/agentReuse.ts +++ b/desktop/src/features/agents/agentReuse.ts @@ -63,6 +63,19 @@ export function findReusablePersonaAgent( return pickPreferredManagedAgent(candidates); } +export function findPersonaAgentInChannel( + agents: ManagedAgent[], + personaId: string, + channelMemberPubkeys: ReadonlySet, +): ManagedAgent | undefined { + const candidates = agents.filter( + (agent) => + agent.personaId === personaId && + channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), + ); + return pickPreferredManagedAgent(candidates); +} + export function findReusableGenericAgent( agents: ManagedAgent[], command: string, diff --git a/desktop/src/features/agents/channelAgents.teamReuse.test.mjs b/desktop/src/features/agents/channelAgents.teamReuse.test.mjs new file mode 100644 index 00000000000..6817e8fcaf0 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.teamReuse.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChannelManagedAgents } from "./channelAgents.ts"; + +const AGENT_PUBKEY = "a".repeat(64); + +function rawAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "corsai", + persona_id: "persona-corsai", + runtime: "codex", + team_id: "team-dpwai", + relay_url: "wss://relay.example", + acp_command: "buzz-acp", + agent_command: "codex-acp", + agent_args: [], + mcp_command: "", + turn_timeout_seconds: 0, + idle_timeout_seconds: 0, + max_turn_duration_seconds: 0, + parallelism: 1, + system_prompt: "Support the team.", + model: null, + provider: null, + persona_out_of_date: false, + persona_orphaned: false, + needs_restart: false, + status: "running", + pid: null, + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + last_error_code: null, + log_path: "", + start_on_app_launch: true, + backend: { type: "local" }, + backend_agent_id: null, + respond_to: "owner-only", + respond_to_allowlist: [], + ...overrides, + }; +} + +function input() { + return { + runtime: { + id: "codex", + label: "Codex", + command: "codex-acp", + defaultArgs: [], + mcpCommand: "", + }, + name: "corsai", + personaId: "persona-corsai", + teamId: "team-dpwai", + systemPrompt: "Support the team.", + respondTo: "owner-only", + ensureRunning: false, + }; +} + +function installTauriInvoke(handler) { + const prior = globalThis.window; + globalThis.window ??= {}; + window.__TAURI_INTERNALS__ = { invoke: handler }; + return () => { + globalThis.window = prior; + }; +} + +test("re-deploying a team reuses its persona already in the channel", async (t) => { + const commands = []; + t.after( + installTauriInvoke((command) => { + commands.push(command); + if (command === "list_managed_agents") + return Promise.resolve([rawAgent()]); + if (command === "get_channel_members") { + return Promise.resolve({ + members: [ + { + pubkey: AGENT_PUBKEY, + role: "bot", + is_agent: true, + joined_at: "2026-01-15T00:00:00Z", + display_name: "corsai", + }, + ], + next_cursor: null, + }); + } + if (command === "add_channel_members") { + return Promise.resolve({ added: [], errors: [] }); + } + throw new Error(`Unexpected Tauri command: ${command}`); + }), + ); + + const result = await createChannelManagedAgents("channel-1", [input()]); + + assert.equal(result.failures.length, 0); + assert.equal(result.successes.length, 1); + assert.equal(result.successes[0].created, false); + assert.equal(result.successes[0].agent.pubkey, AGENT_PUBKEY); + assert.equal(commands.includes("create_managed_agent"), false); +}); + +test("one batch never mints two keys for the same persona", async (t) => { + const commands = []; + t.after( + installTauriInvoke((command) => { + commands.push(command); + if (command === "list_managed_agents") return Promise.resolve([]); + if (command === "get_channel_members") { + return Promise.resolve({ members: [], next_cursor: null }); + } + if (command === "create_managed_agent") { + return Promise.resolve({ + agent: rawAgent(), + private_key_nsec: "nsec-test", + profile_sync_error: null, + spawn_error: null, + }); + } + if (command === "add_channel_members") { + return Promise.resolve({ added: [AGENT_PUBKEY], errors: [] }); + } + throw new Error(`Unexpected Tauri command: ${command}`); + }), + ); + + const result = await createChannelManagedAgents("channel-1", [ + input(), + input(), + ]); + + assert.equal(result.failures.length, 0); + assert.equal(result.successes.length, 2); + assert.equal(result.successes[0].created, true); + assert.equal(result.successes[1].created, false); + assert.equal( + commands.filter((command) => command === "create_managed_agent").length, + 1, + ); +}); + +test("an explicit fresh-instance request still mints a new key", async (t) => { + const commands = []; + t.after( + installTauriInvoke((command) => { + commands.push(command); + if (command === "list_managed_agents") + return Promise.resolve([rawAgent()]); + if (command === "get_channel_members") { + return Promise.resolve({ + members: [ + { + pubkey: AGENT_PUBKEY, + role: "bot", + is_agent: true, + joined_at: "2026-01-15T00:00:00Z", + display_name: "corsai", + }, + ], + next_cursor: null, + }); + } + if (command === "create_managed_agent") { + return Promise.resolve({ + agent: rawAgent({ pubkey: "b".repeat(64) }), + private_key_nsec: "nsec-fresh-test", + profile_sync_error: null, + spawn_error: null, + }); + } + if (command === "add_channel_members") { + return Promise.resolve({ added: ["b".repeat(64)], errors: [] }); + } + throw new Error(`Unexpected Tauri command: ${command}`); + }), + ); + + const freshInput = { ...input(), forceNewInstance: true }; + const result = await createChannelManagedAgents("channel-1", [freshInput]); + + assert.equal(result.failures.length, 0); + assert.equal(result.successes.length, 1); + assert.equal(result.successes[0].created, true); + assert.equal(result.successes[0].agent.pubkey, "b".repeat(64)); + assert.equal( + commands.filter((command) => command === "create_managed_agent").length, + 1, + ); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 24ace21b520..80c7f11eee5 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -1,5 +1,6 @@ import { commandsMatch, + findPersonaAgentInChannel, findReusableGenericAgent, findReusablePersonaAgent, pickPreferredManagedAgent, @@ -72,7 +73,7 @@ export type CreateChannelManagedAgentInput = { systemPrompt?: string; avatarUrl?: string; personaId?: string | null; - /** Team this instance is deployed from; prevents cross-team reuse. */ + /** Team recorded on a newly created instance. */ teamId?: string | null; /** * True when `runtime` is a runtime the user deliberately picked to override @@ -326,6 +327,36 @@ export async function provisionChannelManagedAgent( throw new Error("Agent name is required."); } + // A repeated team deployment must be idempotent. Prefer the persona's + // existing channel member before looking elsewhere or minting a new key. + if ( + input.personaId && + !input.forceNewInstance && + context?.managedAgents && + context.channelMemberPubkeys + ) { + const inChannel = findPersonaAgentInChannel( + context.managedAgents, + input.personaId, + context.channelMemberPubkeys, + ); + if (inChannel) { + const definition = context.personas.find( + (persona) => persona.id === input.personaId, + ); + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( + inChannel, + input, + definition, + ); + return { + agent: updatedAgent, + created: false, + runtimeId: input.runtime.id, + }; + } + } + // Smart reuse: if a managed agent with the same personaId already exists // and is not already in this channel, attach it instead of creating a new one. if ( @@ -466,7 +497,11 @@ export async function createChannelManagedAgents( const channelMemberPubkeys = new Set( members.map((m) => normalizePubkey(m.pubkey)), ); - const context = { managedAgents, channelMemberPubkeys, personas }; + const context: ChannelAgentReuseContext = { + managedAgents: [...managedAgents], + channelMemberPubkeys, + personas, + }; // Sequential loop: each agent must be fully created and its relay membership // written before the next starts. Concurrent writes to the replaceable @@ -479,6 +514,18 @@ export async function createChannelManagedAgents( try { const result = await createChannelManagedAgent(channelId, input, context); successes.push(result); + const normalizedPubkey = normalizePubkey(result.agent.pubkey); + context.channelMemberPubkeys = new Set(context.channelMemberPubkeys).add( + normalizedPubkey, + ); + const existingIndex = context.managedAgents.findIndex( + (agent) => normalizePubkey(agent.pubkey) === normalizedPubkey, + ); + if (existingIndex === -1) { + context.managedAgents.push(result.agent); + } else { + context.managedAgents[existingIndex] = result.agent; + } } catch (error) { failures.push({ kind: input.personaId ? "persona" : "generic", diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index dcb8386497d..ec2f6b9e5a1 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -148,8 +148,6 @@ export function AddTeamToChannelDialog({ model: persona.model ?? undefined, personaId: persona.id, teamId: team.id, - // One persona can be deployed under multiple teams with different instructions. - forceNewInstance: true, role, }; });