diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 52f42304..696fc32c 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -755,6 +755,30 @@ describe('spawnAgent session reattach', () => { ).not.toThrow(); expect(mockPtySpawn).toHaveBeenCalledTimes(1); }); + + it('replaces an existing same-id PTY when attachExisting is explicitly false', () => { + const win = createMockWindow(); + const agentId = 'agent-replace'; + const args = buildSpawnArgs({ + agentId, + command: 'claude', + args: [], + dockerMode: false, + onOutput: { __CHANNEL_ID__: 'channel-1' }, + }); + + spawnAgent(win, args); + const oldProc = mockPtySpawn.mock.results[0].value as ReturnType; + + spawnAgent(win, { + ...args, + attachExisting: false, + onOutput: { __CHANNEL_ID__: 'channel-2' }, + }); + + expect(oldProc.kill).toHaveBeenCalled(); + expect(mockPtySpawn).toHaveBeenCalledTimes(2); + }); }); describe('validateCommand', () => { diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 647f1d0b..1132e8b5 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -137,10 +137,9 @@ export class Coordinator { } }); - // Re-subscribe our output callback when the renderer respawns a managed agent. - // TerminalView kills the existing PTY (clearing all subscribers) then spawns a - // new one with the same agentId. Without this, our outputCb is lost and we - // can never detect idle for that sub-task. + // Re-subscribe our output callback when the renderer reattaches to, or explicitly + // replaces, a managed agent. Without this, our outputCb is lost and we can never + // detect idle for that sub-task. onPtyEvent('spawn', (agentId) => { const outputCb = this.subscribers.get(agentId); if (!outputCb) return; // not a coordinated agent, or initial spawn (not yet subscribed) diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx index 64e40aee..7b554a7e 100644 --- a/src/components/TaskAITerminal.tsx +++ b/src/components/TaskAITerminal.tsx @@ -607,7 +607,7 @@ function AgentTerminalPane(props: { ) } attachExisting={a().attachExisting} - preserveOnWindowUnload + preserveSessionOnCleanup onExit={(code) => markAgentExited(a().id, code)} onData={(data) => markAgentOutput(a().id, data, props.task.id)} onFileLink={props.onFileLink} diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 7a0e7906..ea1ebc0a 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -72,7 +72,7 @@ interface TerminalViewProps { dockerImage?: string; spawnDelayMs?: number; attachExisting?: boolean; - preserveOnWindowUnload?: boolean; + preserveSessionOnCleanup?: boolean; dockerMountWorktreeParent?: boolean; onExit?: (exitInfo: { exit_code: number | null; @@ -127,8 +127,8 @@ export function TerminalView(props: TerminalViewProps) { const taskId = props.taskId; const agentId = props.agentId; const initialFontSize = props.fontSize ?? 13; - const attachExisting = props.attachExisting; - const preserveOnWindowUnload = props.preserveOnWindowUnload === true; + const attachExisting = props.attachExisting ?? true; + const preserveSessionOnCleanup = props.preserveSessionOnCleanup === true; term = new Terminal({ cursorBlink: true, @@ -702,7 +702,7 @@ export function TerminalView(props: TerminalViewProps) { } onCleanup(() => { - const preserveSession = windowUnloading && preserveOnWindowUnload; + const preserveSession = preserveSessionOnCleanup; if (!windowUnloading || preserveSession) { flushPendingInput(); flushPendingResize(); @@ -715,7 +715,7 @@ export function TerminalView(props: TerminalViewProps) { webglAddon?.dispose(); webglAddon = undefined; unregisterTerminal(agentId); - if (preserveSession && ptyPaused) { + if (ptyPaused) { fireAndForget(IPC.ResumeAgent, { agentId }); ptyPaused = false; } diff --git a/src/store/agents.test.ts b/src/store/agents.test.ts new file mode 100644 index 00000000..48e23752 --- /dev/null +++ b/src/store/agents.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSetStore, mockMarkAgentSpawned } = vi.hoisted(() => ({ + mockSetStore: vi.fn(), + mockMarkAgentSpawned: vi.fn(), +})); + +let mockAgents: Record = {}; + +interface AgentLike { + id: string; + taskId: string; + def: AgentDefLike; + resumed: boolean; + status: 'running' | 'exited'; + exitCode: number | null; + signal: string | null; + lastOutput: string[]; + generation: number; + spawnDelayMs?: number; + attachExisting?: boolean; +} + +interface AgentDefLike { + id: string; + name: string; + command: string; + args: string[]; + resume_args: string[]; + skip_permissions_args: string[]; + description: string; +} + +function applySetStore(...args: unknown[]): void { + if (args.length === 1 && typeof args[0] === 'function') { + (args[0] as (s: { agents: Record }) => void)({ agents: mockAgents }); + } +} + +vi.mock('./core', () => ({ + store: new Proxy({} as Record, { + get(_target, prop) { + if (prop === 'agents') return mockAgents; + return undefined; + }, + }), + setStore: mockSetStore, +})); + +vi.mock('./taskStatus', () => ({ + markAgentSpawned: mockMarkAgentSpawned, + refreshTaskStatus: vi.fn(), + clearAgentActivity: vi.fn(), +})); + +vi.mock('./persistence', () => ({ saveState: vi.fn() })); +vi.mock('../lib/ipc', () => ({ invoke: vi.fn() })); + +import { restartAgent, switchAgent } from './agents'; + +const codexDef: AgentDefLike = { + id: 'codex', + name: 'Codex', + command: 'codex', + args: [], + resume_args: ['resume', '--last'], + skip_permissions_args: [], + description: '', +}; + +function exitedAgent(overrides: Partial = {}): AgentLike { + return { + id: 'agent-1', + taskId: 'task-1', + def: codexDef, + resumed: false, + status: 'exited', + exitCode: 1, + signal: '1', + lastOutput: ['interrupted'], + generation: 2, + spawnDelayMs: 500, + attachExisting: true, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args)); + mockAgents = { 'agent-1': exitedAgent() }; +}); + +describe('restartAgent', () => { + it('marks the next terminal mount as an explicit process replacement', () => { + restartAgent('agent-1', true); + + expect(mockAgents['agent-1']).toMatchObject({ + status: 'running', + exitCode: null, + signal: null, + lastOutput: [], + resumed: true, + generation: 3, + attachExisting: false, + }); + expect(mockAgents['agent-1'].spawnDelayMs).toBeUndefined(); + expect(mockMarkAgentSpawned).toHaveBeenCalledWith('agent-1'); + }); +}); + +describe('switchAgent', () => { + it('marks the next terminal mount as an explicit process replacement', () => { + const claudeDef: AgentDefLike = { + ...codexDef, + id: 'claude', + name: 'Claude', + command: 'claude', + }; + + switchAgent('agent-1', claudeDef); + + expect(mockAgents['agent-1']).toMatchObject({ + def: claudeDef, + status: 'running', + exitCode: null, + signal: null, + lastOutput: [], + resumed: false, + generation: 3, + attachExisting: false, + }); + expect(mockAgents['agent-1'].spawnDelayMs).toBeUndefined(); + expect(mockMarkAgentSpawned).toHaveBeenCalledWith('agent-1'); + }); +}); diff --git a/src/store/agents.ts b/src/store/agents.ts index 66409c7c..e1854981 100644 --- a/src/store/agents.ts +++ b/src/store/agents.ts @@ -113,7 +113,7 @@ export function restartAgent(agentId: string, useResumeArgs: boolean): void { s.agents[agentId].lastOutput = []; s.agents[agentId].resumed = useResumeArgs; s.agents[agentId].spawnDelayMs = undefined; - s.agents[agentId].attachExisting = undefined; + s.agents[agentId].attachExisting = false; s.agents[agentId].generation += 1; } }), @@ -132,7 +132,7 @@ export function switchAgent(agentId: string, newDef: AgentDef): void { s.agents[agentId].lastOutput = []; s.agents[agentId].resumed = false; s.agents[agentId].spawnDelayMs = undefined; - s.agents[agentId].attachExisting = undefined; + s.agents[agentId].attachExisting = false; s.agents[agentId].generation += 1; } }),