diff --git a/desktop/src/features/agents/channelAgents.test.mjs b/desktop/src/features/agents/channelAgents.test.mjs new file mode 100644 index 00000000000..f21533cc2f9 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { attachManagedAgentToChannel } from "./channelAgents.ts"; + +const AGENT_PUBKEY = "a".repeat(64); + +function rawAgent(status = "stopped", backend = { type: "local" }) { + return { + pubkey: AGENT_PUBKEY, + name: "reviewer", + persona_id: null, + runtime: null, + team_id: null, + relay_url: "ws://localhost:3000", + acp_command: "buzz-acp", + agent_command: "codex", + agent_command_override: null, + agent_args: [], + mcp_command: "", + turn_timeout_seconds: 30, + idle_timeout_seconds: null, + max_turn_duration_seconds: null, + parallelism: 1, + system_prompt: null, + avatar_url: null, + model: null, + provider: null, + persona_out_of_date: false, + persona_orphaned: false, + needs_restart: false, + restart_diff: [], + env_vars: {}, + status, + pid: status === "running" ? 123 : null, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + last_error_code: null, + log_path: null, + start_on_app_launch: false, + auto_restart_on_config_change: true, + backend, + backend_agent_id: null, + respond_to: "owner-only", + respond_to_allowlist: [], + }; +} + +function managedAgent(status = "stopped", backend = { type: "local" }) { + const raw = rawAgent(status, backend); + return { + ...raw, + personaId: raw.persona_id, + teamId: raw.team_id, + relayUrl: raw.relay_url, + acpCommand: raw.acp_command, + agentCommand: raw.agent_command, + agentCommandOverride: raw.agent_command_override, + agentArgs: raw.agent_args, + mcpCommand: raw.mcp_command, + turnTimeoutSeconds: raw.turn_timeout_seconds, + idleTimeoutSeconds: raw.idle_timeout_seconds, + maxTurnDurationSeconds: raw.max_turn_duration_seconds, + systemPrompt: raw.system_prompt, + avatarUrl: raw.avatar_url, + personaOutOfDate: raw.persona_out_of_date, + personaOrphaned: raw.persona_orphaned, + needsRestart: raw.needs_restart, + restartDiff: raw.restart_diff, + envVars: raw.env_vars, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + lastStartedAt: raw.last_started_at, + lastStoppedAt: raw.last_stopped_at, + lastExitCode: raw.last_exit_code, + lastError: raw.last_error, + lastErrorCode: raw.last_error_code, + logPath: raw.log_path, + startOnAppLaunch: raw.start_on_app_launch, + autoRestartOnConfigChange: raw.auto_restart_on_config_change, + backendAgentId: raw.backend_agent_id, + respondTo: raw.respond_to, + respondToAllowlist: raw.respond_to_allowlist, + }; +} + +function installTauriInvoke(t, handler) { + const prior = globalThis.window; + globalThis.window = { __TAURI_INTERNALS__: { invoke: handler } }; + t.after(() => { + mock.restoreAll(); + globalThis.window = prior; + }); +} + +test("add without starting verifies stopped state through the production seam", async (t) => { + const calls = []; + installTauriInvoke(t, async (command, args) => { + calls.push([command, args]); + if (command === "add_channel_members") { + return { added: [AGENT_PUBKEY], errors: [] }; + } + if (command === "list_managed_agents") { + return [rawAgent("stopped")]; + } + throw new Error(`unexpected command: ${command}`); + }); + + const result = await attachManagedAgentToChannel("channel-1", { + agent: managedAgent("stopped"), + ensureRunning: false, + }); + + assert.equal(result.membershipAdded, true); + assert.equal(result.started, false); + assert.equal(result.agent.status, "stopped"); + assert.deepEqual( + calls.map(([command]) => command), + ["add_channel_members", "list_managed_agents"], + ); +}); + +test("add without starting rejects stale running input before membership", async (t) => { + const calls = []; + installTauriInvoke(t, async (command, args) => { + calls.push([command, args]); + throw new Error(`unexpected command: ${command}`); + }); + + await assert.rejects( + attachManagedAgentToChannel("channel-1", { + agent: managedAgent("running"), + ensureRunning: false, + }), + /no longer stopped/, + ); + assert.deepEqual(calls, []); +}); + +test("add without starting fails closed when membership is not confirmed", async (t) => { + const calls = []; + installTauriInvoke(t, async (command, args) => { + calls.push([command, args]); + if (command === "add_channel_members") { + return { added: [], errors: [] }; + } + throw new Error(`unexpected command: ${command}`); + }); + + await assert.rejects( + attachManagedAgentToChannel("channel-1", { + agent: managedAgent("stopped"), + ensureRunning: false, + }), + /membership was not confirmed/, + ); + assert.deepEqual( + calls.map(([command]) => command), + ["add_channel_members"], + ); +}); + +test("post-membership stopped-state drift is a visible partial failure", async (t) => { + const calls = []; + installTauriInvoke(t, async (command, args) => { + calls.push([command, args]); + if (command === "add_channel_members") { + return { added: [AGENT_PUBKEY], errors: [] }; + } + if (command === "list_managed_agents") { + return [rawAgent("running")]; + } + throw new Error(`unexpected command: ${command}`); + }); + + await assert.rejects( + attachManagedAgentToChannel("channel-1", { + agent: managedAgent("stopped"), + ensureRunning: false, + }), + /was added, but its stopped state could not be verified/, + ); + assert.deepEqual( + calls.map(([command]) => command), + ["add_channel_members", "list_managed_agents"], + ); +}); + +test("the existing default still adds and starts a stopped local agent", async (t) => { + const calls = []; + installTauriInvoke(t, async (command, args) => { + calls.push([command, args]); + if (command === "add_channel_members") { + return { added: [AGENT_PUBKEY], errors: [] }; + } + if (command === "start_managed_agent") { + return rawAgent("running"); + } + throw new Error(`unexpected command: ${command}`); + }); + + const result = await attachManagedAgentToChannel("channel-1", { + agent: managedAgent("stopped"), + }); + + assert.equal(result.membershipAdded, true); + assert.equal(result.started, true); + assert.equal(result.agent.status, "running"); + assert.deepEqual( + calls.map(([command]) => command), + ["add_channel_members", "start_managed_agent"], + ); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 3387d135af1..c3d4402bc07 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -149,6 +149,20 @@ export async function attachManagedAgentToChannel( const role = input.role ?? "bot"; const ensureRunning = input.ensureRunning ?? true; const agentPubkey = normalizePubkey(input.agent.pubkey); + + if (!ensureRunning) { + if (input.agent.backend.type !== "local") { + throw new Error( + "Adding without starting is only available for local agents.", + ); + } + if (input.agent.status !== "stopped") { + throw new Error( + "This agent is no longer stopped. Refresh and choose Add and start instead.", + ); + } + } + const membershipResult = await addChannelMembers({ channelId, pubkeys: [input.agent.pubkey], @@ -163,11 +177,28 @@ export async function attachManagedAgentToChannel( const membershipAdded = membershipResult.added.some( (pubkey) => normalizePubkey(pubkey) === agentPubkey, ); + if (!ensureRunning && !membershipAdded) { + throw new Error("Agent membership was not confirmed."); + } let agent = input.agent; let started = false; - if (ensureRunning) { + if (!ensureRunning) { + try { + const verifiedAgent = (await listManagedAgents()).find( + (candidate) => normalizePubkey(candidate.pubkey) === agentPubkey, + ); + if (verifiedAgent?.status !== "stopped") { + throw new Error("stopped state changed"); + } + agent = verifiedAgent; + } catch { + throw new Error( + "Agent was added, but its stopped state could not be verified.", + ); + } + } else { // Running agents (local or provider) auto-discover new channel membership // via the harness's membership notifications — no restart needed. Only // not-yet-running agents need a start/deploy call before the first mention diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index b059debfc6a..72a4f1ad666 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -19,14 +19,18 @@ export function formatAddCandidateName(user: UserSearchResult) { export function AddMemberSearchResultRow({ disabled, onSelect, + onSelectWithoutStarting, ownerLabel, user, }: { disabled: boolean; onSelect: (user: UserSearchResult) => void; + onSelectWithoutStarting?: (user: UserSearchResult) => void; ownerLabel?: string | null; user: UserSearchResult; }) { + const candidateName = formatAddCandidateName(user); + return (
Searching... diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index db76be349e9..2753bd2acee 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4342,6 +4342,82 @@ test("members sidebar can invite and remove managed agents", async ({ await expectMembersTriggerCount(page, initialMemberCount); }); +test("members sidebar can add a stopped local agent without starting it", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); + await page.goto("/"); + await openMembersSidebar(page, "general"); + await page.getByTestId("channel-management-search-users").fill("char"); + + const result = page.getByTestId( + `channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect( + result.getByRole("button", { + name: "Add charlie without starting", + }), + ).toBeVisible(); + await expect( + result.getByRole("button", { name: "Add charlie and start" }), + ).toBeVisible(); + + const baselineCommands = await readCommandLog(page); + await result + .getByRole("button", { name: "Add charlie without starting" }) + .click(); + + await expect( + page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`), + ).toContainText("charlie"); + + const commandsAfterAction = (await readCommandLog(page)).slice( + baselineCommands.length, + ); + expect(commandsAfterAction).toContain("add_channel_members"); + expect(commandsAfterAction).toContain("list_managed_agents"); + expect(commandsAfterAction).not.toContain("start_managed_agent"); + expect(commandsAfterAction).not.toContain("start_managed_agent_runtime"); + + const agents = await invokeMockCommand< + Array<{ pubkey: string; status: string }> + >(page, "list_managed_agents"); + expect( + agents.find((agent) => agent.pubkey === TEST_IDENTITIES.charlie.pubkey) + ?.status, + ).toBe("stopped"); + + // Reload the isolated mock state and prove the same explicit action is + // keyboard-operable, not only pointer-operable. + await page.reload(); + await openMembersSidebar(page, "general"); + await page.getByTestId("channel-management-search-users").fill("char"); + const keyboardResult = page.getByTestId( + `channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`, + ); + const addWithoutStartingButton = keyboardResult.getByRole("button", { + name: "Add charlie without starting", + }); + await addWithoutStartingButton.focus(); + await expect(addWithoutStartingButton).toBeFocused(); + await addWithoutStartingButton.press("Enter"); + await expect( + page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`), + ).toContainText("charlie"); + expect(await readCommandLog(page)).not.toContain("start_managed_agent"); + expect(await readCommandLog(page)).not.toContain( + "start_managed_agent_runtime", + ); +}); + test("members sidebar pages add-member search beyond the first 50 people", async ({ page, }) => {