diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 843e1837938..4bca3a31d47 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -13,7 +13,7 @@ import { } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { AgentHookServer, _internals } from './server' +import { AgentHookServer, agentHookServer, _internals } from './server' import { AGENT_STATUS_MAX_FIELD_LENGTH, parseAgentStatusPayload @@ -69,6 +69,855 @@ afterEach(() => { }) describe('AgentHookServer listener replay', () => { + it('applies inferred interrupts through the cached status lifecycle', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + prompt: 'long task', + agentType: 'codex', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + payload: expect.objectContaining({ state: 'done', interrupted: true }) + }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'opencode' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'opencode', + intent: 'plain-escape', + inputCount: 2 + }) + ).toBe(true) + + vi.setSystemTime(1_501) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + prompt: 'long task', + agentType: 'opencode', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + payload: expect.objectContaining({ state: 'done', interrupted: true }) + }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('rejects inferred interrupts when a same-millisecond prompt update changed the row', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'first task', agentType: 'codex' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'second task', agentType: 'codex' } + }, + 'conn-1' + ) + + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'first task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'second task', + agentType: 'codex' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it.each(['opencode', 'copilot'] as const)( + 'rejects single plain Escape inference for %s', + (agentType) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: agentType, + intent: 'plain-escape' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType + }) + ]) + } finally { + vi.useRealTimers() + } + } + ) + + it.each(['opencode', 'copilot'] as const)( + 'accepts double plain Escape inference for %s', + (agentType) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: agentType, + intent: 'plain-escape', + inputCount: 2 + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'long task', + agentType, + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + } + ) + + it('rejects Ctrl+C inference for Droid', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'droid' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'droid', + intent: 'ctrl-c' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType: 'droid' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('does not let late same-turn working hooks resurrect an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(6_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'long task', + agentType: 'pi', + toolName: 'bash', + toolInput: '/bin/sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'long task', + agentType: 'pi', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows a new prompt after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'first task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'first task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'second task', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'second task', + agentType: 'pi', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows an immediate same-prompt retry after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'retryable task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'retryable task', + agentType: 'pi', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows a same-prompt working hook after the stale suppression window', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'repeat task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(16_501) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'repeat task', + agentType: 'pi', + toolName: 'bash', + toolInput: '/bin/sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'repeat task', + agentType: 'pi', + receivedAt: 16_501, + stateStartedAt: 16_501 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('rejects malformed inferred interrupt requests without throwing', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + const malformed: unknown[] = [ + { + paneKey: 'tab-1:0', + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 1_000, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }, + { + paneKey: PANE, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 1_000, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'sigint' + }, + { + paneKey: PANE, + baselineUpdatedAt: '1_000', + baselineStateStartedAt: 1_000, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }, + { + paneKey: PANE, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 1_000, + baselinePrompt: 123, + baselineAgentType: 'codex', + intent: 'ctrl-c' + } + ] + + for (const request of malformed) { + expect(() => + server.inferInterrupt(request as Parameters[0]) + ).not.toThrow() + expect( + server.inferInterrupt(request as Parameters[0]) + ).toBe(false) + } + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType: 'codex' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows an immediate same-prompt retry that carries cached turn detail', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'retryable task', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses replayed same-prompt working events after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'retryable task', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(20_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + isReplay: true, + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'retryable task', + agentType: 'opencode', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('matches renderer unknown sentinel to an omitted hook agent type', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'custom hook' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'custom hook', + baselineAgentType: 'unknown', + intent: 'ctrl-c' + }) + ).toBe(true) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'custom hook', + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('rejects inferred interrupts for stale and non-working rows', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'waiting', prompt: 'permission', agentType: 'codex' } + }, + 'conn-1' + ) + const waiting = server.getStatusSnapshot()[0] + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: waiting.receivedAt, + baselineStateStartedAt: waiting.stateStartedAt, + baselinePrompt: 'permission', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + ).toBe(false) + + server.ingestRemote( + { + paneKey: FRESH_PANE, + tabId: 'tab-fresh', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'old task', agentType: 'codex' } + }, + 'conn-1' + ) + const stale = server.getStatusSnapshot().find((entry) => entry.paneKey === FRESH_PANE)! + vi.setSystemTime(stale.receivedAt + 30 * 60 * 1000 + 1) + expect( + server.inferInterrupt({ + paneKey: FRESH_PANE, + baselineUpdatedAt: stale.receivedAt, + baselineStateStartedAt: stale.stateStartedAt, + baselinePrompt: 'old task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + ).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('applies inferred interrupts for arbitrary agent types and Ctrl+C intent', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'custom task', agentType: 'custom-agent' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot().find((entry) => entry.paneKey === GOOD_PANE)! + + vi.setSystemTime(1_250) + expect( + server.inferInterrupt({ + paneKey: GOOD_PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'custom task', + baselineAgentType: 'custom-agent', + intent: 'ctrl-c' + }) + ).toBe(true) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: GOOD_PANE, + state: 'done', + prompt: 'custom task', + agentType: 'custom-agent', + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + }) + it('allows multiple status-change subscribers to observe the same update', () => { const server = new AgentHookServer() const first = vi.fn() @@ -1242,6 +2091,76 @@ describe('Cursor hook normalization', () => { expect(result?.payload.lastAssistantMessage).toBe('Done — wrote the README.') }) + it('late afterAgentResponse after stop keeps Cursor done instead of resurrecting working', () => { + const submit = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }), + 'production' + ) + expect(submit).not.toBeNull() + if (!submit) { + throw new Error('expected Cursor beforeSubmitPrompt to normalize') + } + agentHookServer.ingestRemote( + { + paneKey: submit.paneKey, + tabId: submit.tabId, + worktreeId: submit.worktreeId, + payload: submit.payload + }, + 'conn-1' + ) + + const stop = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'stop', status: 'completed' }), + 'production' + ) + expect(stop).not.toBeNull() + if (!stop) { + throw new Error('expected Cursor stop to normalize') + } + agentHookServer.ingestRemote( + { + paneKey: stop.paneKey, + tabId: stop.tabId, + worktreeId: stop.worktreeId, + payload: stop.payload + }, + 'conn-1' + ) + + const response = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'afterAgentResponse', text: 'All set.' }), + 'production' + ) + expect(response?.payload.state).toBe('done') + expect(response?.payload.lastAssistantMessage).toBe('All set.') + if (!response) { + throw new Error('expected Cursor afterAgentResponse to normalize') + } + + agentHookServer.ingestRemote( + { + paneKey: response.paneKey, + tabId: response.tabId, + worktreeId: response.worktreeId, + payload: response.payload + }, + 'conn-1' + ) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + agentType: 'cursor', + prompt: 'add tests', + lastAssistantMessage: 'All set.' + }) + ]) + }) + it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => { _internals.normalizeHookPayload( 'cursor', diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ce16aa0d65a..47c7bc44d06 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -36,10 +36,16 @@ import { } from '../../shared/agent-hook-listener' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { + AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload, + type AgentType, type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { + isAgentInterruptInputIntent, + type AgentInterruptInferenceRequest +} from '../../shared/agent-interrupt-intent' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' @@ -78,6 +84,7 @@ type PaneKeyAliasEntry = { const LAST_STATUS_FILE_NAME = 'last-status.json' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 +const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 // Why: starts at 2 (not 1) because pre-merge dev iterations of this branch // wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a @@ -106,6 +113,15 @@ type LastStatusFile = { entries: Record } +function equivalentInterruptAgentType( + actual: AgentType | undefined, + baseline: AgentType | undefined +): boolean { + const normalizedActual = actual === 'unknown' ? undefined : actual + const normalizedBaseline = baseline === 'unknown' ? undefined : baseline + return normalizedActual === normalizedBaseline +} + // Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix // at write/hydrate time so legacy numeric rows fail closed. export function isValidPaneKey(value: unknown): value is string { @@ -173,6 +189,7 @@ function sanitizeHydratedEntry( tabId: typeof tabId === 'string' ? tabId : undefined, worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, connectionId, + hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, payload, receivedAt, stateStartedAt @@ -279,6 +296,70 @@ export class AgentHookServer { ) } + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + if (!isAgentInterruptInputIntent(request.intent)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + const payload = existing.payload + const agentType: AgentType | undefined = payload.agentType + // Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C + // exits the CLI, which is handled by process/PTY lifecycle cleanup. + if (agentType === 'droid' && request.intent === 'ctrl-c') { + return false + } + // Why: these agents use the first Escape as a TUI/editor cancel. A single + // Escape can leave the turn running, so only a deliberate double Escape + // may infer an interrupted turn. + if ( + (agentType === 'opencode' || agentType === 'copilot') && + request.intent === 'plain-escape' && + request.inputCount !== 2 + ) { + return false + } + // Why: input-intent inference is a fallback for a missing final hook. A strict + // baseline match keeps a delayed timer from overwriting any newer hook, + // including same-millisecond prompt or agent identity changes. + if ( + payload.state !== 'working' || + !equivalentInterruptAgentType(agentType, request.baselineAgentType) || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + payload: { + state: 'done', + prompt: payload.prompt, + agentType, + interrupted: true + } + }) + console.debug('[agent-hooks] inferred interrupted agent status', { + paneKey: inferred.paneKey, + agentType, + intent: request.intent + }) + return true + } + getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => { const enriched = entry as EnrichedAgentHookEventPayload @@ -319,6 +400,33 @@ export class AgentHookServer { } private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + // Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already + // stopped the turn. Do not let that stale same-turn event resurrect the row. + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + payload.payload.state === 'done' && + previous.payload.agentType === payload.payload.agentType && + previous.payload.prompt === payload.payload.prompt && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + ) { + return previous + } + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + payload.payload.state === 'working' && + previous.payload.agentType === payload.payload.agentType && + previous.payload.prompt === payload.payload.prompt && + (payload.isReplay === true || + (payload.hasExplicitPrompt !== true && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) + ) { + return previous + } if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) { this.clearAssistantMessageRetry(payload.paneKey) } @@ -517,6 +625,8 @@ export class AgentHookServer { worktreeId?: string env?: string version?: string + hasExplicitPrompt?: boolean + isReplay?: boolean payload: unknown }, connectionId: string @@ -592,6 +702,8 @@ export class AgentHookServer { tabId, worktreeId, connectionId: trimmedConnectionId, + hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, + isReplay: envelope.isReplay === true ? true : undefined, payload: normalizedPayload } this.applyNormalizedStatus(event) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c7f1146239a..2ff8273e24d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -245,6 +245,10 @@ export class DaemonPtyAdapter implements IPtyProvider { }) } + hasPty(id: string): boolean { + return this.activeSessionIds.has(id) + } + write(id: string, data: string): void { this.markSessionDirty(id) this.sendNotification('write', { sessionId: id, data }) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index fee4c11b563..725a14c38d5 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -29,6 +29,7 @@ function createAdapter( title: label })) ), + hasPty: vi.fn((id: string) => sessions.includes(id)), write: vi.fn((id: string, data: string) => { writes.push({ id, data }) }), @@ -120,6 +121,20 @@ describe('DaemonPtyRouter', () => { expect(current.spawn).toHaveBeenCalledWith({ sessionId: 'legacy-session', cols: 80, rows: 24 }) }) + it('uses mapped adapter liveness instead of routing-cache presence for hasPty', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await router.discoverLegacySessions() + expect(router.hasPty('legacy-session')).toBe(true) + + await router.shutdown('legacy-session', { keepHistory: true }) + + expect(router.hasPty('legacy-session')).toBe(false) + expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session') + }) + it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index b0e6c263aad..e1668457b29 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -55,6 +55,14 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).attach(id) } + hasPty(id: string): boolean { + const routed = this.sessionAdapters.get(id) + if (routed) { + return routed.hasPty(id) + } + return this.current.hasPty(id) || this.legacy.some((adapter) => adapter.hasPty(id)) + } + write(id: string, data: string): void { this.adapterFor(id).write(id, data) } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 49549d03e2b..8574f7791e6 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -197,6 +197,39 @@ describe('createPtySubprocess', () => { expect(env.ORCA_WORKTREE_ID).toBe('child-worktree') }) + it('does not inherit parent agent hook endpoint for development hook env', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousEndpoint = process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_ENDPOINT = '/tmp/stale-endpoint.env' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { + ORCA_AGENT_HOOK_ENV: 'development', + ORCA_AGENT_HOOK_PORT: '1234', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_AGENT_HOOK_VERSION: '1' + } + }) + } finally { + if (previousEndpoint === undefined) { + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + } else { + process.env.ORCA_AGENT_HOOK_ENDPOINT = previousEndpoint + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENV).toBe('development') + expect(env.ORCA_AGENT_HOOK_PORT).toBe('1234') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('token') + }) + it('forwards write calls', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index b1a4514b9ab..2db622e1592 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -63,6 +63,17 @@ function removeUnspecifiedPaneIdentityEnv( } } +function removeInheritedDevAgentHookEndpoint( + env: Record, + explicitEnv: Record | undefined +): void { + if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development') { + // Why: the daemon inherits the app process env before per-PTY env is + // merged, so dev terminals must explicitly drop a parent endpoint.env. + delete env.ORCA_AGENT_HOOK_ENDPOINT + } +} + function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' @@ -165,6 +176,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // Why: the daemon is forked from Electron and can inherit the pane identity // of the terminal that launched `pn dev`; each PTY must opt into its own. removeUnspecifiedPaneIdentityEnv(env, opts.env) + removeInheritedDevAgentHookEndpoint(env, opts.env) removeInheritedNoColor(env) env.LANG ??= 'en_US.UTF-8' diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 835ba210543..72c89445f86 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -9,6 +9,7 @@ import { makePaneKey } from '../../shared/stable-pane-id' const dropStatusEntry = vi.fn() const getStatusSnapshot = vi.fn() +const inferInterrupt = vi.fn() const onHandlers = new Map void>() const handleHandlers = new Map unknown>() const removeHandler = vi.fn() @@ -36,7 +37,8 @@ vi.mock('../agent-hooks/server', async () => { ...actual, agentHookServer: { dropStatusEntry, - getStatusSnapshot + getStatusSnapshot, + inferInterrupt } } }) @@ -66,6 +68,7 @@ vi.mock('../hermes/hook-service', () => ({ beforeEach(() => { dropStatusEntry.mockReset() getStatusSnapshot.mockReset() + inferInterrupt.mockReset() onHandlers.clear() handleHandlers.clear() removeHandler.mockReset() @@ -98,6 +101,40 @@ describe('agentStatus:getSnapshot IPC', () => { }) }) +describe('agentStatus:inferInterrupt IPC', () => { + it('forwards valid inference requests to the hook server', async () => { + inferInterrupt.mockReturnValue(true) + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentStatus:inferInterrupt') + expect(handler).toBeDefined() + const request = { + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'ctrl-c' + } + + expect(handler!({}, request)).toBe(true) + expect(inferInterrupt).toHaveBeenCalledWith(request) + }) + + it('rejects malformed requests before the hook server boundary', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentStatus:inferInterrupt') + expect(handler).toBeDefined() + for (const value of [null, undefined, '', 123, true]) { + expect(handler!({}, value)).toBe(false) + } + expect(inferInterrupt).not.toHaveBeenCalled() + }) +}) + describe('agentStatus:drop IPC', () => { it('forwards drop to dropStatusEntry', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 1e45dd3c9d0..87543078910 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -4,6 +4,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import { clearMigrationUnsupportedPtysForPaneKey, @@ -38,6 +39,7 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:copilotStatus') ipcMain.removeHandler('agentHooks:hermesStatus') ipcMain.removeHandler('agentStatus:getSnapshot') + ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't @@ -64,6 +66,12 @@ export function registerAgentHookHandlers(): void { // lose replayed statuses while its local store is still empty. return agentHookServer.getStatusSnapshot() }) + ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => { + if (typeof request !== 'object' || request === null) { + return false + } + return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest) + }) ipcMain.handle( 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d07b7857c0b..c30d7e4e6b8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -192,6 +192,7 @@ describe('registerPtyHandlers', () => { delete process.env.OPENCODE_CONFIG_DIR delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR delete process.env.ORCA_OPENCODE_CONFIG_DIR + delete process.env.ORCA_AGENT_HOOK_ENDPOINT delete process.env.PI_CODING_AGENT_DIR delete process.env.ORCA_PI_SOURCE_AGENT_DIR delete process.env.ORCA_PI_CODING_AGENT_DIR @@ -805,6 +806,23 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => { + const { app } = await import('electron') + const mockedApp = app as unknown as { isPackaged: boolean } + const prev = mockedApp.isPackaged + mockedApp.isPackaged = false + try { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, { + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + }) + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + } finally { + mockedApp.isPackaged = prev + } + }) + it('prepends attribution shims on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ enableGitHubAttribution: true @@ -2622,6 +2640,26 @@ describe('registerPtyHandlers', () => { } }) + it('acknowledges pty writes only for owned PTYs', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + + expect(handlers.get('pty:writeAccepted')!(null, { id: result.id, data: '\x03' })).toBe(true) + expect(mockProc.proc.write).toHaveBeenCalledWith('\x03') + expect( + handlers.get('pty:writeAccepted')!(null, { + id: 'missing-pty-for-write-ack', + data: '\x03' + }) + ).toBe(false) + expect(mockProc.proc.write).toHaveBeenCalledTimes(1) + }) + it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => { registerPtyHandlers(mainWindow as never) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 53531e4ceef..77383870653 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -313,6 +313,9 @@ export function buildPtyHostEnv( // must inject the loopback receiver coordinates before the agent starts. // Without these env vars the global hook config cannot map callbacks back // to the correct Orca pane. + // Why: nested Orca terminals can inherit another process's hook endpoint or + // token. Strip all hook runtime coordinates before injecting this PTY's fresh + // server values so callbacks route to the owning app/runtime. for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { delete baseEnv[key] } @@ -583,6 +586,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:serializeBuffer:response') @@ -1607,7 +1611,7 @@ export function registerPtyHandlers( } ) - ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { + const writePtyInput = (args: { id: string; data: string }): boolean => { // Why: defense-in-depth for the mobile-presence lock. The renderer's // xterm.onData guard already drops desktop keystrokes when mobile is // driving, but a stale view between the main-side state flip and the @@ -1615,14 +1619,50 @@ export function registerPtyHandlers( // This server-side check catches it. See // docs/mobile-presence-lock.md. if (runtime?.getDriver(args.id).kind === 'mobile') { - return + return false } const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined if (!provider) { - return + return false } - lastInputAtByPty.set(args.id, performance.now()) - provider.write(args.id, args.data) + try { + lastInputAtByPty.set(args.id, performance.now()) + provider.write(args.id, args.data) + return true + } catch { + return false + } + } + + const writePtyInputAccepted = (args: { id: string; data: string }): boolean => { + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + // Why: the acknowledgement is used to infer Ctrl+C/Escape actually reached + // the local PTY. SSH providers are fire-and-forget relay notifications, so + // they cannot truthfully acknowledge until the relay protocol grows a write + // request/response. + if (ptyOwnership.get(args.id) !== null) { + return false + } + const provider = tryGetProviderForPty(args.id) + if (!provider?.hasPty?.(args.id)) { + return false + } + try { + lastInputAtByPty.set(args.id, performance.now()) + provider.write(args.id, args.data) + return true + } catch { + return false + } + } + + ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => { + writePtyInput(args) + }) + ipcMain.handle('pty:writeAccepted', (_event, args: { id: string; data: string }): boolean => { + return writePtyInputAccepted(args) }) // Why: resize is fire-and-forget — the renderer doesn't need a reply. diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 74576c68059..12587930981 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -440,6 +440,9 @@ export class LocalPtyProvider implements IPtyProvider { // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. async attach(_id: string): Promise {} + hasPty(id: string): boolean { + return ptyProcesses.has(id) + } write(id: string, data: string): void { ptyProcesses.get(id)?.write(data) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index f51114008f6..143f7a99d7e 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -83,6 +83,7 @@ export type PtySpawnResult = { export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise + hasPty?: (id: string) => boolean write(id: string, data: string): void resize(id: string, cols: number, rows: number): void shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index b1b6154e0b2..5e782c69326 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this integration spec keeps the SSH relay, +agent-hook server, and replay/interrupt ordering fixtures together so regressions +cover the full mux-to-main path. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' @@ -324,4 +327,120 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { expect(events).toHaveLength(0) expect(agentHookServer.getStatusSnapshot()).toEqual([]) }) + + it('preserves explicit-prompt metadata from remote hook notifications', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + platform: 'linux-x64' + }) + + session = createSession('conn-explicit-prompt') + await session.establish({} as SshConnection) + + relay.notifyAgentHook( + makeEnvelope({ + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'retry same prompt', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }) + ) + await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), { + timeout: 1500 + }) + const first = agentHookServer.getStatusSnapshot()[0] + + expect( + agentHookServer.inferInterrupt({ + paneKey: first.paneKey, + baselineUpdatedAt: first.receivedAt, + baselineStateStartedAt: first.stateStartedAt, + baselinePrompt: 'retry same prompt', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + ).toBe(true) + + relay.notifyAgentHook( + makeEnvelope({ + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'retry same prompt', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }) + ) + + await vi.waitFor(() => + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'retry same prompt', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + }) + ) + }) + + it('preserves replay metadata from remote hook notifications', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + platform: 'linux-x64' + }) + + session = createSession('conn-replay-marker') + await session.establish({} as SshConnection) + + relay.notifyAgentHook( + makeEnvelope({ + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'replayed prompt', + agentType: 'opencode' + } + }) + ) + await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), { + timeout: 1500 + }) + const first = agentHookServer.getStatusSnapshot()[0] + + expect( + agentHookServer.inferInterrupt({ + paneKey: first.paneKey, + baselineUpdatedAt: first.receivedAt, + baselineStateStartedAt: first.stateStartedAt, + baselinePrompt: 'replayed prompt', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + ).toBe(true) + + relay.notifyAgentHook( + makeEnvelope({ + hasExplicitPrompt: true, + isReplay: true, + payload: { + state: 'working', + prompt: 'replayed prompt', + agentType: 'opencode' + } + }) + ) + + await new Promise((resolve) => setImmediate(resolve)) + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'replayed prompt', + agentType: 'opencode', + interrupted: true + }) + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 1c0b0b1c7e3..142e2bdba1a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -583,6 +583,8 @@ export class SshRelaySession { worktreeId?: unknown env?: unknown version?: unknown + hasExplicitPrompt?: unknown + isReplay?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { @@ -600,6 +602,8 @@ export class SshRelaySession { worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, env: typeof envelope.env === 'string' ? envelope.env : undefined, version: typeof envelope.version === 'string' ? envelope.version : undefined, + hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, + isReplay: envelope.isReplay === true ? true : undefined, payload: envelope.payload }, this.targetId diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cab919c09ca..c6cbe3632e2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -161,6 +161,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -708,6 +709,7 @@ export type PreloadApi = { coldRestore?: { scrollback: string; cwd: string } }> write: (id: string, data: string) => void + writeAccepted: (id: string, data: string) => Promise resize: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void @@ -1921,6 +1923,7 @@ export type PreloadApi = { onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise + inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise /** Listen for PTYs that still use a legacy numeric pane key but have * registry-backed UUID pane proof. */ onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index a8ca87f6c6f..97ce2406f07 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -605,6 +606,8 @@ const api = { write: (id: string, data: string): void => { ipcRenderer.send('pty:write', { id, data }) }, + writeAccepted: (id: string, data: string): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data }), resize: (id: string, cols: number, rows: number): void => { ipcRenderer.send('pty:resize', { id, cols, rows }) @@ -3051,6 +3054,8 @@ const api = { * knows which tabs exist. */ getSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getSnapshot'), + inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferInterrupt', request), onMigrationUnsupported: ( callback: (entry: MigrationUnsupportedPtyEntry) => void ): (() => void) => { diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index a03ea8cc564..1f14539f411 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -117,6 +117,7 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls[0][0].source).toBe('claude') expect(forward.mock.calls[0][0].env).toBe('remote') expect(forward.mock.calls[0][0].version).toBe('1') + expect(forward.mock.calls[0][0].isReplay).toBe(true) } finally { server.stop() } diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index a537a62d037..f1d12d1f84b 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -176,7 +176,7 @@ export class RelayAgentHookServer { if (!meta) { continue } - this.forwardEvent(event, meta.source, meta.env, meta.version) + this.forwardEvent(event, meta.source, meta.env, meta.version, { isReplay: true }) count++ } return count @@ -267,7 +267,8 @@ export class RelayAgentHookServer { event: AgentHookEventPayload, source: AgentHookSource, env?: string, - version?: string + version?: string, + options: { isReplay?: boolean } = {} ): void { const envelope: AgentHookRelayEnvelope = { source, @@ -275,6 +276,8 @@ export class RelayAgentHookServer { tabId: event.tabId, worktreeId: event.worktreeId, connectionId: null, + hasExplicitPrompt: event.hasExplicitPrompt, + isReplay: options.isReplay === true ? true : undefined, env, version, payload: event.payload diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts new file mode 100644 index 00000000000..780137e1184 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -0,0 +1,395 @@ +/* eslint-disable max-lines -- Why: this suite locks the interrupt inference state machine across several agent-specific keyboard semantics. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + createAgentInterruptInference, + isCtrlCKeyEvent, + isPlainEscapeKeyEvent +} from './agent-interrupt-inference' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function makeEntry(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: 'write tests', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey: PANE_KEY, + terminalTitle: 'Codex', + stateHistory: [], + ...overrides + } +} + +function keyEvent(overrides: Partial): KeyboardEvent { + return { + key: '', + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false, + ...overrides + } as KeyboardEvent +} + +describe('agent interrupt inference', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each([['ctrl-c', 'custom-agent']] as const)( + 'emits a strict baseline request for %s from %s after the settle window', + (intent, agentType) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent(intent) + vi.advanceTimersByTime(499) + expect(inferInterrupt).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: agentType, + intent + }) + tracker.dispose() + entry = undefined + } + ) + + it.each([ + ['plain-escape', 'gemini'], + ['ctrl-c', 'gemini'] + ] as const)( + 'emits a strict baseline request for %s from Gemini immediately', + (intent, agentType) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent(intent) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: agentType, + intent + }) + tracker.dispose() + entry = undefined + } + ) + + it('emits when the working row has no agent type', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: undefined }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('ctrl-c') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: undefined, + intent: 'ctrl-c' + }) + tracker.dispose() + entry = undefined + }) + + it('does not infer Ctrl+C for Droid', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'droid' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('ctrl-c') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + entry = undefined + }) + + it.each(['opencode', 'copilot'] as const)( + 'infers immediately on double Escape for %s', + (agentType) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + expect(inferInterrupt).not.toHaveBeenCalled() + + tracker.observeInputIntent('plain-escape') + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: agentType, + intent: 'plain-escape', + inputCount: 2 + }) + tracker.dispose() + entry = undefined + } + ) + + it('does not count an OpenCode Escape across a new turn', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + entry = makeEntry({ agentType: 'opencode', prompt: 'second task', stateStartedAt: 1_050 }) + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + entry = undefined + }) + + it.each(['opencode', 'copilot'] as const)( + 'does not count a %s Escape after the double-Escape window expires', + (agentType) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + vi.advanceTimersByTime(500) + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + entry = undefined + } + ) + + it('does not emit again for a third OpenCode Escape after the row is already done', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn((request) => { + entry = makeEntry({ + state: 'done', + agentType: request.baselineAgentType, + prompt: request.baselinePrompt, + updatedAt: 1_500, + stateStartedAt: 1_500 + }) + }) + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + + expect(inferInterrupt).toHaveBeenCalledTimes(1) + tracker.dispose() + entry = undefined + }) + + it('still infers Ctrl+C for OpenCode', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('ctrl-c') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + tracker.dispose() + entry = undefined + }) + + it('does not emit for non-working states', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + let entry: AgentStatusEntry | undefined = makeEntry({ state: 'waiting', agentType: 'codex' }) + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + }) + + it('cancels when a newer hook update arrives during the settle window', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + let entry: AgentStatusEntry | undefined = makeEntry() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + entry = makeEntry({ updatedAt: 1_001 }) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + }) + + it('cancels when a normal done hook arrives during the settle window', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + let entry: AgentStatusEntry | undefined = makeEntry() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('ctrl-c') + entry = makeEntry({ state: 'done', updatedAt: 1_050, stateStartedAt: 1_050 }) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + }) + + it('emits the captured baseline when the renderer status disappears during the settle window', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + let entry: AgentStatusEntry | undefined = makeEntry() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + entry = undefined + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + tracker.dispose() + }) + + it('dispose cancels a pending inference timer', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => makeEntry(), + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('ctrl-c') + tracker.dispose() + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + }) + + it('requires exact plain Escape and Ctrl+C key events', () => { + expect(isPlainEscapeKeyEvent(keyEvent({ key: 'Escape' }))).toBe(true) + expect(isCtrlCKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(true) + expect(isCtrlCKeyEvent(keyEvent({ key: 'C', ctrlKey: true }))).toBe(true) + for (const event of [ + keyEvent({ key: 'Escape', altKey: true }), + keyEvent({ key: 'Escape', shiftKey: true }), + keyEvent({ key: 'Escape', repeat: true }), + keyEvent({ key: 'c', ctrlKey: true, metaKey: true }), + keyEvent({ key: 'c', ctrlKey: true, repeat: true }) + ]) { + expect(isPlainEscapeKeyEvent(event) || isCtrlCKeyEvent(event)).toBe(false) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts new file mode 100644 index 00000000000..801afd2440c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -0,0 +1,242 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { + AGENT_INTERRUPT_SETTLE_MS, + type AgentInterruptInferenceRequest, + type AgentInterruptInputIntent +} from '../../../../shared/agent-interrupt-intent' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' + +export type AgentInterruptInference = { + observeInputIntent(intent: AgentInterruptInputIntent): void + flushPending(): boolean | Promise + dispose(): void +} + +type AgentInterruptInferenceDeps = { + paneKey: string + getStatusEntry: () => AgentStatusEntry | undefined + inferInterrupt: (request: AgentInterruptInferenceRequest) => boolean | Promise | void + now?: () => number + setTimer?: (callback: () => void, ms: number) => ReturnType + clearTimer?: (timer: ReturnType) => void +} + +type CapturedInterruptBaseline = { + updatedAt: number + stateStartedAt: number + prompt: string + agentType: AgentStatusEntry['agentType'] + intent: AgentInterruptInputIntent + inputCount?: number +} + +function requiresDoubleEscapeForAgent( + agentType: AgentStatusEntry['agentType'], + intent: AgentInterruptInputIntent +): boolean { + return (agentType === 'opencode' || agentType === 'copilot') && intent === 'plain-escape' +} + +function shouldFlushInterruptImmediately( + baseline: Pick +): boolean { + return ( + requiresDoubleEscapeForAgent(baseline.agentType, baseline.intent) || + baseline.agentType === 'gemini' + ) +} + +function shouldIgnoreInterruptIntent( + agentType: AgentStatusEntry['agentType'], + intent: AgentInterruptInputIntent +): boolean { + return agentType === 'droid' && intent === 'ctrl-c' +} + +function isSameTurnBaseline( + left: CapturedInterruptBaseline, + right: CapturedInterruptBaseline +): boolean { + return ( + left.agentType === right.agentType && + left.prompt === right.prompt && + left.stateStartedAt === right.stateStartedAt + ) +} + +export function isPlainEscapeKeyEvent( + event: Pick +): boolean { + return ( + event.key === 'Escape' && + !event.repeat && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) +} + +export function isCtrlCKeyEvent( + event: Pick +): boolean { + return ( + event.key.toLowerCase() === 'c' && + !event.repeat && + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) +} + +export function createAgentInterruptInference({ + paneKey, + getStatusEntry, + inferInterrupt, + now = () => Date.now(), + setTimer = (callback, ms) => setTimeout(callback, ms), + clearTimer = (timer) => clearTimeout(timer) +}: AgentInterruptInferenceDeps): AgentInterruptInference { + let pendingTimer: ReturnType | null = null + let pendingBaseline: CapturedInterruptBaseline | null = null + let doubleEscapeBaseline: CapturedInterruptBaseline | null = null + let doubleEscapeTimer: ReturnType | null = null + + const clearPendingTimer = (): void => { + if (pendingTimer !== null) { + clearTimer(pendingTimer) + pendingTimer = null + } + pendingBaseline = null + } + + const clearDoubleEscapeBaseline = (): void => { + doubleEscapeBaseline = null + if (doubleEscapeTimer !== null) { + clearTimer(doubleEscapeTimer) + doubleEscapeTimer = null + } + } + + const clearPending = (): void => { + clearPendingTimer() + clearDoubleEscapeBaseline() + } + + const captureBaseline = ( + entry: AgentStatusEntry, + intent: AgentInterruptInputIntent + ): CapturedInterruptBaseline | null => { + const agentType = entry.agentType + if ( + entry.state !== 'working' || + !isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS) + ) { + return null + } + return { + updatedAt: entry.updatedAt, + stateStartedAt: entry.stateStartedAt, + prompt: entry.prompt, + agentType, + intent + } + } + + const flushPending = (): boolean | Promise => { + const baseline = pendingBaseline + pendingTimer = null + pendingBaseline = null + if (!baseline) { + return false + } + + const entry = getStatusEntry() + if ( + entry && + (entry.state !== 'working' || + entry.agentType !== baseline.agentType || + entry.prompt !== baseline.prompt || + entry.updatedAt !== baseline.updatedAt || + entry.stateStartedAt !== baseline.stateStartedAt || + !isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS)) + ) { + return false + } + if (!entry && now() - baseline.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return false + } + + const result = inferInterrupt({ + paneKey, + baselineUpdatedAt: baseline.updatedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: baseline.agentType, + intent: baseline.intent, + ...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {}) + }) + return result ?? true + } + + const flushPendingFromTimer = (): void => { + void flushPending() + } + + return { + observeInputIntent(intent) { + const entry = getStatusEntry() + if (!entry) { + clearPending() + return + } + let baseline = captureBaseline(entry, intent) + if (!baseline) { + clearPending() + return + } + if (shouldIgnoreInterruptIntent(baseline.agentType, intent)) { + clearPending() + return + } + if (requiresDoubleEscapeForAgent(baseline.agentType, intent)) { + const isSecondEscape = + doubleEscapeBaseline !== null && isSameTurnBaseline(doubleEscapeBaseline, baseline) + doubleEscapeBaseline = baseline + clearPendingTimer() + if (!isSecondEscape) { + if (doubleEscapeTimer !== null) { + clearTimer(doubleEscapeTimer) + } + // Why: some TUIs use the first Escape as an editor/menu cancel. Do + // not let that arm a later single Escape indefinitely. + doubleEscapeTimer = setTimer(clearDoubleEscapeBaseline, AGENT_INTERRUPT_SETTLE_MS) + return + } + clearDoubleEscapeBaseline() + // Why: these agents use the first Escape as a TUI/editor cancel. The + // second Escape on the same turn is the actual running-turn interrupt. + baseline = { ...baseline, inputCount: 2 } + } else { + clearDoubleEscapeBaseline() + clearPendingTimer() + } + pendingBaseline = baseline + if (shouldFlushInterruptImmediately(baseline)) { + // Why: these agents can emit their idle/done hook immediately after an + // accepted interrupt. Flush before that hook overwrites the working baseline. + void flushPending() + return + } + pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS) + }, + flushPending, + dispose() { + clearPending() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index a6cedd11881..c940126d84e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -62,6 +62,7 @@ type StoreState = { removeDeferredSshSessionId: ReturnType consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType + runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record setAgentStatus: ReturnType removeAgentStatus: ReturnType @@ -81,6 +82,7 @@ type MockTransport = { ) => unknown } sendInput: ReturnType + sendInputAccepted?: ReturnType resize: ReturnType getPtyId: ReturnType } @@ -121,9 +123,15 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { ...actual, isGeminiTerminalTitle: vi.fn(() => false), isClaudeAgent: vi.fn(() => false), - detectAgentStatusFromTitle: vi.fn((title: string) => - /Claude (working|done)/.test(title) ? (/working/.test(title) ? 'working' : 'idle') : null - ) + detectAgentStatusFromTitle: vi.fn((title: string) => { + if (/Claude (working|done)/.test(title)) { + return /working/.test(title) ? 'working' : 'idle' + } + if (/Codex( working)?/.test(title)) { + return /working/.test(title) ? 'working' : 'idle' + } + return null + }) } }) @@ -181,7 +189,7 @@ vi.mock('./remote-runtime-pty-transport', () => ({ function createMockTransport(initialPtyId: string | null = null): MockTransport { let ptyId = initialPtyId - return { + const transport = { attach: vi.fn(({ existingPtyId }: { existingPtyId: string }) => { ptyId = existingPtyId }), @@ -196,6 +204,9 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport resize: vi.fn(() => true), getPtyId: vi.fn(() => ptyId) } as MockTransport + const sendInput = transport.sendInput as unknown as (data: string) => boolean + transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data)) + return transport } function createPane(paneId: number) { @@ -211,6 +222,7 @@ function createPane(paneId: number) { onData: vi.fn(() => ({ dispose: vi.fn() })), onResize: vi.fn(() => ({ dispose: vi.fn() })), onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), parser: { registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) } @@ -268,6 +280,54 @@ function createDeps(overrides: Record = {}) { } } +function createKeyboardEventTarget() { + const handlers = new Set<(event: KeyboardEvent) => void>() + return { + handlers, + target: { + addEventListener: vi.fn( + ( + type: string, + handler: EventListenerOrEventListenerObject, + _options?: AddEventListenerOptions | boolean + ) => { + if (type === 'keydown' && typeof handler === 'function') { + handlers.add(handler as (event: KeyboardEvent) => void) + } + } + ), + removeEventListener: vi.fn( + ( + type: string, + handler: EventListenerOrEventListenerObject, + _options?: EventListenerOptions | boolean + ) => { + if (type === 'keydown' && typeof handler === 'function') { + handlers.delete(handler as (event: KeyboardEvent) => void) + } + } + ) + }, + dispatch(event: KeyboardEvent) { + for (const handler of handlers) { + handler(event) + } + } + } +} + +function keyEvent(overrides: Partial): KeyboardEvent { + return { + key: '', + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false, + ...overrides + } as KeyboardEvent +} + function createDeferred(): { promise: Promise; resolve: (value: T) => void } { let resolveDeferred!: (value: T) => void const promise = new Promise((resolve) => { @@ -308,6 +368,7 @@ describe('connectPanePty', () => { removeDeferredSshSessionId: vi.fn(), consumePendingColdRestore: vi.fn(() => null), consumePendingSnapshot: vi.fn(() => null), + runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, setAgentStatus: vi.fn((paneKey: string, payload: Record) => { mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -341,6 +402,9 @@ describe('connectPanePty', () => { notifications: { dispatch: vi.fn().mockResolvedValue({ delivered: true }), playSound: vi.fn().mockResolvedValue({ played: true }) + }, + agentStatus: { + inferInterrupt: vi.fn().mockResolvedValue(false) } } } @@ -446,6 +510,656 @@ describe('connectPanePty', () => { ) }) + it('infers interrupts only from the focused terminal key target', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop this task', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const unrelatedTarget = createKeyboardEventTarget() + ;( + globalThis.window as unknown as { addEventListener?: ReturnType } + ).addEventListener = vi.fn() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + unrelatedTarget.dispatch({ + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false + } as KeyboardEvent) + vi.advanceTimersByTime(500) + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(globalThis.window.addEventListener).not.toHaveBeenCalled() + + terminalTarget.dispatch({ + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false + } as KeyboardEvent) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop this task', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + }) + + it('clears stale working pane title after inferred interrupt applies', async () => { + const { connectPanePty } = await import('./pty-connection') + const { resolveWorktreeStatus } = await import('@/lib/worktree-status') + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop visible spinner', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, manager as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') + expect( + resolveWorktreeStatus({ + tabs: [{ id: 'tab-1', title: 'Codex working' }], + browserTabs: [], + ptyIdsByTabId: { 'tab-1': ['tab-pty'] }, + runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: true, + hasRetainedDone: false + }) + ).toBe('done') + }) + + it('clears unchanged title-only working indicators after acknowledged interrupt input', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, manager as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') + }) + + it('does not clear title-only working indicators when interrupt writes are unacknowledged', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + delete transport.sendInputAccepted + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(deps.updateTabTitle).not.toHaveBeenCalled() + }) + + it('infers exact Ctrl+C terminal input when keydown capture misses the press', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop from real terminal byte', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop from real terminal byte', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + }) + + it('infers captured Ctrl+C even when xterm emits an enhanced keyboard sequence', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop enhanced keyboard input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u') + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop enhanced keyboard input', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + }) + + it('infers interrupt for an explicit working status even if the title is already non-agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop after process exit', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Terminal 1' + } + } + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop after process exit', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('keeps inferred interrupted status after the pane settles on a non-agent title', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop and leave shell', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'stop and leave shell', + interrupted: true, + updatedAt: 1_100, + stateStartedAt: 1_100, + agentType: 'codex', + paneKey, + terminalTitle: 'Terminal 1' + } + return true + }) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('does not infer exact interrupt input when the transport cannot acknowledge writes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + delete transport.sendInputAccepted + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'ssh style write', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('removes agent status and pane title on PTY exit after inferred interrupt', async () => { + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop then exit', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + if (!onPtyExit) { + throw new Error('expected onPtyExit callback to be registered') + } + + onPtyExit('tab-pty') + + expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'tab-pty') + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null) + expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(paneKey) + }) + + it('ignores repeated and modified terminal interrupt keydowns', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'keep running', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + for (const event of [ + { + key: 'Escape', + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: true + }, + { + key: 'Escape', + ctrlKey: false, + metaKey: false, + altKey: true, + shiftKey: false, + repeat: false + }, + { + key: 'c', + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: true, + repeat: false + } + ]) { + terminalTarget.dispatch(event as KeyboardEvent) + } + vi.advanceTimersByTime(500) + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer Ctrl+C when terminal selection turns the chord into copy', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'copy selection', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown; hasSelection: ReturnType }).element = + terminalTarget.target + ;(pane.terminal as { hasSelection: ReturnType }).hasSelection.mockReturnValue( + true + ) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + vi.advanceTimersByTime(500) + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('removes the terminal key listener on dispose before a remount adds another', async () => { + const { connectPanePty } = await import('./pty-connection') + const firstTransport = createMockTransport() + const secondTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, secondTransport) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + + const firstConnection = connectPanePty( + pane as never, + createManager(1) as never, + createDeps() as never + ) + expect(terminalTarget.handlers.size).toBe(1) + + firstConnection.dispose() + expect(terminalTarget.handlers.size).toBe(0) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + expect(terminalTarget.handlers.size).toBe(1) + expect(terminalTarget.target.addEventListener).toHaveBeenCalledTimes(2) + expect(terminalTarget.target.removeEventListener).toHaveBeenCalledTimes(1) + }) + it('does not reuse a sibling split pane pending spawn after remount', async () => { const { connectPanePty } = await import('./pty-connection') @@ -540,6 +1254,8 @@ describe('connectPanePty', () => { it('blocks input to stale Codex panes until they restart', async () => { const { connectPanePty } = await import('./pty-connection') + vi.useFakeTimers() + vi.setSystemTime(1_100) const transport = createMockTransport('pty-codex-stale') transportFactoryQueue.push(transport) mockStoreState = { @@ -552,10 +1268,23 @@ describe('connectPanePty', () => { }, codexRestartNoticeByPtyId: { 'pty-codex-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' } + }, + agentStatusByPaneKey: { + [makePaneKey('tab-1', LEAF_1)]: { + paneKey: makePaneKey('tab-1', LEAF_1), + state: 'working', + prompt: 'stale input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } } } const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target let onDataHandler: ((data: string) => void) | null = null pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { onDataHandler = handler @@ -571,9 +1300,150 @@ describe('connectPanePty', () => { throw new Error('expected onData handler to be registered') } const sendTerminalInput = onDataHandler as (data: string) => void - sendTerminalInput('hello') + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + sendTerminalInput('\x03') + vi.advanceTimersByTime(500) expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer interrupts when mobile presence lock blocks terminal input', async () => { + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const ptyId = 'pty-mobile-locked' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'locked input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + } + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + vi.advanceTimersByTime(500) + + expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + + it('does not infer interrupts when the transport rejects terminal input', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const transport = createMockTransport('pty-disconnected') + transport.sendInput.mockReturnValue(false) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'working', + prompt: 'disconnected input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + vi.advanceTimersByTime(500) + + expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + }) + + it('does not infer interrupts when the main process rejects acknowledged input', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.useFakeTimers() + vi.setSystemTime(1_100) + const transport = createMockTransport('pty-mobile-race') + transport.sendInputAccepted = vi.fn().mockResolvedValue(false) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'working', + prompt: 'mobile race input', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + stateHistory: [] + } + + const pane = createPane(1) + const terminalTarget = createKeyboardEventTarget() + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => { @@ -702,6 +1572,155 @@ describe('connectPanePty', () => { expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) + it('flushes pending interrupt inference before dropping an exited foreground agent command', async () => { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + const writeAccepted = createDeferred() + transport.sendInputAccepted = vi.fn(() => writeAccepted.promise) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return { id: 'tab-pty' } + }) + transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + }) + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'stop quickly', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + terminalTitle: 'Codex', + stateHistory: [] + } + } + } + vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'done', + prompt: 'stop quickly', + updatedAt: 1_100, + stateStartedAt: 1_100, + agentType: 'codex', + terminalTitle: 'Codex', + interrupted: true, + stateHistory: [ + { + state: 'working', + prompt: 'stop quickly', + startedAt: 900 + } + ] + } + return true + }) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + vi.advanceTimersByTime(1_000) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch( + keyEvent({ + key: 'c', + ctrlKey: true + }) + ) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + + writeAccepted.resolve(true) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop quickly', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + }) + + it('drops the command-finished status when pending interrupt inference is rejected', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(false) + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return { id: 'tab-pty' } + }) + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'stop quickly', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + terminalTitle: 'Codex', + stateHistory: [] + } + } + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + vi.advanceTimersByTime(1_000) + await flushAsyncTicks() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + }) + it('reattaches a remounted split pane to its restored leaf PTY instead of the tab-level PTY', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -2473,10 +3492,9 @@ describe('connectPanePty', () => { expect('agentInterrupted' in dispatchArgs).toBe(false) }) - // Why: onAgentExited must clear any running prompt-cache countdown so the - // sidebar does not show a stale timer for a tab that no longer has an - // active Claude session. - it('clears the cache timer when the agent exits', async () => { + // Why: title reversion clears cache UI, but agent-row removal belongs to + // process/PTY lifecycle so interrupts cannot disappear the activity row. + it('clears the cache timer without removing agent status when the title tracker sees exit', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transportFactoryQueue.push(transport) @@ -2495,5 +3513,6 @@ describe('connectPanePty', () => { agentExitedHandler() expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b5879e6f727..09e3aaf73ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,7 +1,11 @@ /* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { IDisposable } from '@xterm/xterm' -import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status' +import { + detectAgentStatusFromTitle, + isGeminiTerminalTitle, + isClaudeAgent +} from '@/lib/agent-status' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import type { PtyConnectResult } from './pty-transport' @@ -30,6 +34,15 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { e2eConfig } from '@/lib/e2e-config' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' +import { + createAgentInterruptInference, + isCtrlCKeyEvent, + isPlainEscapeKeyEvent +} from './agent-interrupt-inference' +import { + AGENT_INTERRUPT_SETTLE_MS, + type AgentInterruptInputIntent +} from '../../../../shared/agent-interrupt-intent' import { createAgentCompletionCoordinator } from './agent-completion-coordinator' const pendingSpawnByPaneKey = new Map>() @@ -193,19 +206,234 @@ export function connectPanePty( // Use the stable layout leaf UUID, not the renderer-local numeric pane id. const cacheKey = makePaneKey(deps.tabId, pane.leafId) const pendingSpawnKey = cacheKey + const neutralTerminalTitle = (): string => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + ) + return tab?.defaultTitle?.trim() || 'Terminal' + } + const clearInferredInterruptWorkingTitle = (): void => { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle + const title = currentTitle ?? statusTitle + if (!title) { + return + } + const neutralTitle = neutralTerminalTitle() + // Why: inferred interrupts update the explicit hook row, but many CLIs leave + // their OSC title stuck on a working spinner. Replace only this fallback + // title signal with a neutral terminal label so the existing process tracker + // can still decide whether an agent TUI is truly alive. + deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) + if (manager.getActivePane()?.id === pane.id) { + deps.updateTabTitle(deps.tabId, neutralTitle) + } + } + let titleOnlyInterruptTimer: ReturnType | null = null + const clearTitleOnlyInterruptTimer = (): void => { + if (titleOnlyInterruptTimer !== null) { + clearTimeout(titleOnlyInterruptTimer) + titleOnlyInterruptTimer = null + } + } + const observeTitleOnlyInterrupt = (): void => { + const state = useAppStore.getState() + if (state.agentStatusByPaneKey[cacheKey]) { + return + } + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + const baselineTitle = runtimeTitle ?? tabTitle + if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') { + return + } + clearTitleOnlyInterruptTimer() + titleOnlyInterruptTimer = setTimeout(() => { + titleOnlyInterruptTimer = null + if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { + return + } + const currentState = useAppStore.getState() + const currentRuntimeTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const currentTabTitle = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + const currentTitle = currentRuntimeTitle ?? currentTabTitle + if ( + currentTitle === baselineTitle && + detectAgentStatusFromTitle(currentTitle ?? '') === 'working' + ) { + // Why: title-only agents such as Pi can miss their own idle title after + // Ctrl+C. Clear only an unchanged, acknowledged working title. + clearInferredInterruptWorkingTitle() + } + }, AGENT_INTERRUPT_SETTLE_MS) + } + const interruptInference = createAgentInterruptInference({ + paneKey: cacheKey, + getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], + inferInterrupt: (request) => { + // Why: the explicit hook row is the authority for an in-flight agent turn. + // Codex can reset its terminal title while handling Ctrl+C/Escape, so title + // state must not veto clearing the row's working state. + return window.api.agentStatus + .inferInterrupt(request) + .then((applied) => { + if (applied) { + clearInferredInterruptWorkingTitle() + } + return applied + }) + .catch((err) => { + console.warn('[agent-interrupt] inferInterrupt failed:', err) + return false + }) + } + }) + const dropCommandFinishedStatusIfSameTurn = ( + entry: AgentStatusEntry | undefined, + options?: { allowInferredInterrupt?: boolean } + ): void => { + if (!entry) { + return + } + const state = useAppStore.getState() + const current = state.agentStatusByPaneKey[cacheKey] + if (!current) { + return + } + const unchanged = + current.state === entry.state && + current.prompt === entry.prompt && + current.updatedAt === entry.updatedAt && + current.stateStartedAt === entry.stateStartedAt && + current.agentType === entry.agentType + const inferredFromEntry = + options?.allowInferredInterrupt === true && + current.state === 'done' && + current.interrupted === true && + current.prompt === entry.prompt && + current.agentType === entry.agentType && + current.stateHistory?.some( + (history) => + history.state === entry.state && + history.prompt === entry.prompt && + history.startedAt === entry.stateStartedAt + ) === true + if (!unchanged && !inferredFromEntry) { + return + } + state.dropAgentStatus(cacheKey) + } + let pendingTerminalInputIntent: AgentInterruptInputIntent | null = null + let clearPendingTerminalInputIntentTimer: ReturnType | null = null + const clearPendingTerminalInputIntent = (): void => { + pendingTerminalInputIntent = null + if (clearPendingTerminalInputIntentTimer !== null) { + clearTimeout(clearPendingTerminalInputIntentTimer) + clearPendingTerminalInputIntentTimer = null + } + } + const setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => { + clearPendingTerminalInputIntent() + pendingTerminalInputIntent = intent + clearPendingTerminalInputIntentTimer = setTimeout(() => { + clearPendingTerminalInputIntent() + }, 0) + } + const inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => { + return ( + (intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03') + ) + } + const inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => { + if (data === '\x03') { + return 'ctrl-c' + } + if (data === '\x1b') { + return 'plain-escape' + } + return null + } + const observeSentTerminalInputIntent = ( + data: string, + intent = pendingTerminalInputIntent + ): void => { + if (intent && inputMatchesIntent(intent, data)) { + interruptInference.observeInputIntent(intent) + observeTitleOnlyInterrupt() + } + } + let pendingTerminalInputWrite: Promise | null = null + const setPendingTerminalInputWrite = (promise: Promise): void => { + pendingTerminalInputWrite = promise + void promise.finally(() => { + if (pendingTerminalInputWrite === promise) { + pendingTerminalInputWrite = null + } + }) + } + const flushPendingInterruptInference = (): boolean | Promise => { + const pendingWrite = pendingTerminalInputWrite + if (!pendingWrite) { + return interruptInference.flushPending() + } + return pendingWrite.then(() => interruptInference.flushPending()) + } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] + const inferenceResult = flushPendingInterruptInference() + if (inferenceResult === true) { + // Why: OSC 133 D means the foreground shell command exited. If an + // interrupt was inferred first, drop only when the current interrupted + // row is still the same turn; otherwise a killed OpenCode CLI leaves a + // stale "interrupted" row even though the process is gone. + dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) + }) + return + } // Why: OSC 133 D marks the foreground shell command exiting. Remove the // row without retaining a done snapshot; this section represents a live // agent process, and the shell prompt means that process is gone. - if (entry) { - state.dropAgentStatus(cacheKey) - } + dropCommandFinishedStatusIfSameTurn(entry) } }) commandLifecycle.attachXtermConsumer(pane.terminal) + const onTerminalKeyDown = (event: KeyboardEvent): void => { + if (isPlainEscapeKeyEvent(event)) { + setPendingTerminalInputIntent('plain-escape') + return + } + if (isCtrlCKeyEvent(event)) { + if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) { + return + } + setPendingTerminalInputIntent('ctrl-c') + } + } + // Why: infer only from focused xterm key events. Raw PTY bytes cannot + // distinguish plain Escape from Alt/meta sequences, and programmatic writes + // should not clear agent status. + const terminalKeyTarget = pane.terminal.element ?? pane.container + const terminalKeyTargetSupportsEvents = + typeof terminalKeyTarget?.addEventListener === 'function' && + typeof terminalKeyTarget?.removeEventListener === 'function' + if (terminalKeyTargetSupportsEvents) { + terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) + } const agentCompletionCoordinator = createAgentCompletionCoordinator({ paneKey: cacheKey, @@ -511,10 +739,9 @@ export function connectPanePty( // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. deps.setCacheTimerStartedAt(cacheKey, null) - // Why: do not let terminal-title reversion own agent-status lifecycle. - // Explicit hooks and OSC 133 command-finished marks are the reliable - // signals; title changes can race normal "done" states and make agents - // look like they vanished as soon as they finished responding. + clearTitleOnlyInterruptTimer() + // Why: title reversion alone is not process death. The process/PTY tracker + // owns removing agent rows when the TUI actually exits. } // Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their // callbacks to the correct Orca pane without resolving worktrees from cwd. @@ -611,6 +838,7 @@ export function connectPanePty( // the block still holds during reconnect races before the live transport has // updated its local PTY binding. if (isCodexPaneStale({ tabId: deps.tabId, panePtyId: currentPtyId })) { + clearPendingTerminalInputIntent() return } // Why: presence-lock input drop. While mobile is the driver for this @@ -621,6 +849,7 @@ export function connectPanePty( // The pty:write IPC has a defense-in-depth twin. See // docs/mobile-presence-lock.md. if (currentPtyId && isPtyLocked(currentPtyId)) { + clearPendingTerminalInputIntent() return } // Why: a real keystroke into the terminal is the unambiguous "user is @@ -629,7 +858,38 @@ export function connectPanePty( // auto-replies never count as interaction. deps.clearTerminalTabUnread(deps.tabId) deps.clearWorktreeUnread(deps.worktreeId) - transport.sendInput(data) + const intent = pendingTerminalInputIntent + // Why: real xterm can deliver the terminal byte even when our DOM keydown + // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to + // infer for local/remote acknowledged writes; SSH fire-and-forget remains + // excluded because those transports do not expose sendInputAccepted. + const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) + if (acknowledgedIntent && transport.sendInputAccepted) { + clearPendingTerminalInputIntent() + const writePromise = transport + .sendInputAccepted(data) + .then((accepted) => { + if (accepted) { + interruptInference.observeInputIntent(acknowledgedIntent) + observeTitleOnlyInterrupt() + } + }) + .catch((err) => { + console.warn('[agent-interrupt] acknowledged terminal input failed:', err) + }) + setPendingTerminalInputWrite(writePromise) + return + } + if (intent) { + transport.sendInput(data) + clearPendingTerminalInputIntent() + return + } + if (transport.sendInput(data)) { + observeSentTerminalInputIntent(data) + } else { + clearPendingTerminalInputIntent() + } }) const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { @@ -1484,6 +1744,13 @@ export function connectPanePty( return { dispose() { disposed = true + if (terminalKeyTargetSupportsEvents) { + terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) + } + clearPendingTerminalInputIntent() + pendingTerminalInputWrite = null + interruptInference.dispose() + clearTitleOnlyInterruptTimer() // Why: actively resolve any in-flight passphrase-gate waits so their // zustand subscribers + async IIFEs don't hang for the rest of the // session when the pane is torn down before SSH state changes. diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 77a06c550ea..cfb8a17721f 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -264,6 +264,7 @@ export type PtyTransport = { }) => void disconnect: () => void sendInput: (data: string) => boolean + sendInputAccepted?: (data: string) => Promise resize: ( cols: number, rows: number, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 11f52ab328d..e0e521a9351 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -28,6 +28,7 @@ describe('createIpcPtyTransport', () => { ...originalWindow?.api?.pty, spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), write: vi.fn(), + writeAccepted: vi.fn().mockResolvedValue(true), resize: vi.fn(), kill: vi.fn(), onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { @@ -67,6 +68,19 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('uses acknowledged writes only for local IPC PTYs', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const localTransport = createIpcPtyTransport({}) + + await localTransport.connect({ url: '', callbacks: {} }) + await expect(localTransport.sendInputAccepted?.('\x03')).resolves.toBe(true) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03') + + const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' }) + await sshTransport.connect({ url: '', callbacks: {} }) + expect(sshTransport.sendInputAccepted).toBeUndefined() + }) + it('suppresses attention side effects when replaying eager-buffered data during attach', async () => { // Why: eager PTY buffers capture output produced before the pane mounted — // typically catch-up bytes from a previous app session. A BEL or diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 98d88b58097..0b77f32e879 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -520,6 +520,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return true }, + ...(connectionId + ? {} + : { + async sendInputAccepted(data: string): Promise { + if (!connected || !ptyId) { + return false + } + return window.api.pty.writeAccepted(ptyId, data) + } + }), + resize(cols: number, rows: number): boolean { if (!connected || !ptyId) { return false diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index cc86cd5df80..7f902bae7f8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -1,5 +1,6 @@ export type RemoteRuntimePtyBatcher = { push: (data: string) => void + takePending: () => string flush: () => void clear: () => void } @@ -25,14 +26,19 @@ export function createRemoteRuntimePtyTextBatcher( } const flush = (): void => { - const text = pending - pending = '' - clear() + const text = takePending() if (text) { onFlush(text) } } + const takePending = (): string => { + const text = pending + pending = '' + clear() + return text + } + return { push(data: string): void { pending += data @@ -40,6 +46,7 @@ export function createRemoteRuntimePtyTextBatcher( timer = setTimeout(flush, delayMs) } }, + takePending, flush, clear } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 5af58efc465..1eabea0ea0f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -661,6 +661,111 @@ describe('createRemoteRuntimePtyTransport', () => { } }) + it('returns runtime acceptance for acknowledged terminal input', async () => { + runtimeCall.mockImplementation((args) => { + if (args.method === 'terminal.create') { + return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + } + if (args.method === 'terminal.send') { + return Promise.resolve({ + ok: true, + result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } } + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + + await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.send', + params: { + terminal: 'terminal-1', + text: '\x03', + client: { id: 'desktop:tab-1:pane:1', type: 'desktop' } + }, + timeoutMs: 15_000 + }) + }) + + it('preserves queued remote input order before acknowledged terminal input', async () => { + vi.useFakeTimers() + try { + runtimeCall.mockImplementation((args) => { + if (args.method === 'terminal.create') { + return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + } + if (args.method === 'terminal.send') { + return Promise.resolve({ + ok: true, + result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 2 } } + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + subscriptionSendBinary.mockClear() + + expect(transport.sendInput('a')).toBe(true) + await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + await vi.runOnlyPendingTimersAsync() + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.send', + params: { + terminal: 'terminal-1', + text: 'a\x03', + client: { id: 'desktop:tab-1:pane:1', type: 'desktop' } + }, + timeoutMs: 15_000 + }) + expect(subscriptionSendBinary).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('returns false when acknowledged terminal input is rejected by the runtime', async () => { + runtimeCall.mockImplementation((args) => { + if (args.method === 'terminal.create') { + return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } }) + } + if (args.method === 'terminal.send') { + return Promise.resolve({ + ok: true, + result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } } + }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + + await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false) + }) + it('preserves literal LF input when sending remote PTY binary frames', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 8cdcd8f7017..ff2962d5783 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2,7 +2,8 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import type { RuntimeMobileSessionTabsResult, - RuntimeTerminalCreate + RuntimeTerminalCreate, + RuntimeTerminalSend } from '../../../../shared/runtime-types' import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' import { createPtyOutputProcessor } from './pty-transport' @@ -206,6 +207,28 @@ export function createRemoteRuntimePtyTransport( } } + async function sendInputAcceptedToRuntime(data: string): Promise { + const targetHandle = handle + if (!connected || !targetHandle) { + return false + } + if (!data) { + return true + } + const text = `${inputBatcher.takePending()}${data}` + try { + const result = await callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', { + terminal: targetHandle, + text, + client: { id: clientId, type: 'desktop' } + }) + return result.send.accepted === true + } catch (error) { + storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)) + return false + } + } + const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => { const targetHandle = handle if (!connected || !targetHandle) { @@ -458,6 +481,8 @@ export function createRemoteRuntimePtyTransport( return true }, + sendInputAccepted: sendInputAcceptedToRuntime, + resize(cols: number, rows: number): boolean { if (!connected || !handle) { return false diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 683c9d79551..b76ff725e5b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -191,6 +191,7 @@ function createWebPreloadApi(): Partial { agentStatus: { onSet: () => noopUnsubscribe, getSnapshot: () => Promise.resolve([]), + inferInterrupt: () => Promise.resolve(false), onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), @@ -1080,6 +1081,7 @@ function createPtyApi(): NonNullable['pty']> { return { spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), write: () => {}, + writeAccepted: () => Promise.resolve(false), resize: () => {}, reportGeometry: () => {}, signal: () => {}, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 447e6e68800..58a9aa85d5e 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -133,6 +133,11 @@ export type AgentHookEventPayload = { * HTTP path always sets null because it cannot know which mux a request * came from. See docs/design/agent-status-over-ssh.md §5. */ connectionId: string | null + /** True when this hook event carried prompt text directly, instead of using + * the listener's cached prompt from an earlier event in the same pane. */ + hasExplicitPrompt?: boolean + /** True when this event is a relay cache replay rather than a live hook. */ + isReplay?: boolean payload: ParsedAgentStatusPayload } @@ -1523,19 +1528,25 @@ function normalizeCursorEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + // Why: Cursor can emit the final response text after `stop`; that should + // enrich the completed row, not resurrect the agent as working. + const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload const stateName = eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' || eventName === 'preToolUse' || eventName === 'postToolUse' || - eventName === 'postToolUseFailure' || - eventName === 'afterAgentResponse' + eventName === 'postToolUseFailure' ? 'working' - : eventName === 'stop' || eventName === 'sessionEnd' - ? 'done' - : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' - ? 'waiting' - : null + : eventName === 'afterAgentResponse' + ? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor' + ? 'done' + : 'working' + : eventName === 'stop' || eventName === 'sessionEnd' + ? 'done' + : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' + ? 'waiting' + : null if (!stateName) { return null @@ -1973,7 +1984,16 @@ export function normalizeHookPayload( // it null; the relay forwards null on the wire and Orca's `ingestRemote` // stamps the real value from `mux` identity on receive. See // docs/design/agent-status-over-ssh.md §5. - return payload ? { paneKey, tabId, worktreeId, connectionId: null, payload } : null + return payload + ? { + paneKey, + tabId, + worktreeId, + connectionId: null, + hasExplicitPrompt: promptText.length > 0, + payload + } + : null } // ─── URL routing ──────────────────────────────────────────────────── diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 81f5fd48b40..6e9af71cfde 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -54,6 +54,11 @@ export type AgentHookRelayEnvelope = { worktreeId?: string /** Always `null` on the wire — relay does not know Orca's local connectionId. */ connectionId: null + /** Preserved from the relay-side normalized hook event so Orca can + * distinguish a true same-prompt retry from a cached-prompt tool ping. */ + hasExplicitPrompt?: boolean + /** True when the relay is replaying its cache after Orca reconnects. */ + isReplay?: boolean /** Forwarded from the agent CLI POST body. The relay default is `remote`, * which marks transport location rather than dev/prod build env. */ env?: string diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts new file mode 100644 index 00000000000..d058afa8049 --- /dev/null +++ b/src/shared/agent-interrupt-intent.ts @@ -0,0 +1,19 @@ +import type { AgentType } from './agent-status-types' + +export type AgentInterruptInputIntent = 'plain-escape' | 'ctrl-c' + +export const AGENT_INTERRUPT_SETTLE_MS = 500 + +export type AgentInterruptInferenceRequest = { + paneKey: string + baselineUpdatedAt: number + baselineStateStartedAt: number + baselinePrompt: string + baselineAgentType: AgentType | undefined + intent: AgentInterruptInputIntent + inputCount?: number +} + +export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent { + return intent === 'plain-escape' || intent === 'ctrl-c' +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 26f01d48374..a5b8c92c8df 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -1,7 +1,9 @@ // ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // These types define the normalized status that Orca receives from Claude, -// Codex, and other explicit integrations. Agent state is hook-reported only — -// we do not infer status from terminal titles anywhere in the data flow. +// Codex, and other explicit integrations. Agent state normally comes from +// hooks; a narrow interrupt fallback may synthesize a final done state when an +// agent misses its own cancellation hook. We still do not infer status from +// terminal titles anywhere in the data flow. export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] @@ -39,10 +41,10 @@ export type AgentStateHistoryEntry = { prompt: string /** When this state was first reported. */ startedAt: number - /** True when this `done` was a cancellation (user hit ESC/Ctrl+C). Reported - * by the agent itself — Claude Code sets `is_interrupt: true` on its `Stop` - * hook when the turn ended via interrupt. Always falsy for non-`done` - * states, so retention logic can preserve this signal. */ + /** True when this `done` was a cancellation. May come from an agent hook + * (for example Claude Code `is_interrupt`) or Orca's guarded interrupt + * fallback. Always falsy for non-`done` states, so retention logic can + * preserve this signal. */ interrupted?: boolean } @@ -77,10 +79,11 @@ export type AgentStatusEntry = { /** Most recent assistant message preview, when the hook carried one. */ lastAssistantMessage?: string /** True when the current `done` state was reached via an interrupt rather - * than a normal turn completion (Claude Code's `is_interrupt: true`). + * than a normal turn completion. May be reported by the agent itself or + * inferred by Orca's guarded interrupt fallback. * Orthogonal to `state`: the agent still finished the turn, but the user - * cancelled it. Undefined while the agent is working or for non-Claude - * agents that don't surface this signal. */ + * cancelled it. Undefined while the agent is working or when no interrupt + * signal was available. */ interrupted?: boolean } diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index c17f48ba03f..5ebccc1dea3 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -1,5 +1,7 @@ +/* eslint-disable max-lines -- Localhost SSH E2E covers setup, remote PTY, hook relay, and interrupt inference in one expensive app boot. */ import os from 'os' +import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { @@ -72,6 +74,65 @@ function emitMarkerCommand(value: string): string { )}` } +async function focusTerminal(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[worktreeId] ?? null) + if (!tabId) { + throw new Error('No active terminal tab') + } + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error('No active terminal pane') + } + pane.terminal.focus() + }) +} + +async function postCodexHook( + page: Page, + ptyId: string, + payload: Record, + markerName: string +): Promise { + const hookPostedMarker = marker(markerName) + await execInTerminal( + page, + ptyId, + [ + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' echo __ORCA_AGENT_HOOK_ENV_MISSING__', + 'else', + ` hook_payload=${shellQuote(JSON.stringify(payload))}`, + ' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${hook_payload}" >/dev/null; then', + ` ${emitMarkerCommand(hookPostedMarker)}`, + ' fi', + 'fi' + ].join('\n') + ) + await waitForTerminalOutput(page, hookPostedMarker, 20_000) +} + test.describe('Localhost SSH', () => { test.skip( !RUN_LOCALHOST_SSH, @@ -201,6 +262,17 @@ test.describe('Localhost SSH', () => { }) const paneKeyLeafId = paneKey.slice(paneKey.indexOf(':') + 1) expect(paneKeyLeafId).toMatch(UUID_RE) + await orcaPage.evaluate(() => { + const state = window as unknown as { + __sshAgentStatusEvents?: unknown[] + __sshAgentStatusUnsubscribe?: () => void + } + state.__sshAgentStatusEvents = [] + state.__sshAgentStatusUnsubscribe?.() + state.__sshAgentStatusUnsubscribe = window.api.agentStatus.onSet((event) => { + state.__sshAgentStatusEvents?.push(event) + }) + }) const terminalMarker = marker('LOCALHOST_SSH') await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker)) @@ -240,30 +312,12 @@ test.describe('Localhost SSH', () => { await waitForTerminalOutput(orcaPage, pluginOverlayMarker, 20_000) const prompt = `orca ssh e2e prompt ${Date.now()}` - const hookPostedMarker = marker('AGENT_HOOK_POSTED') - await execInTerminal( + await postCodexHook( orcaPage, ptyId, - [ - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' echo __ORCA_AGENT_HOOK_ENV_MISSING__', - 'else', - ` hook_payload=${shellQuote(JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }))}`, - ' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload=${hook_payload}" >/dev/null; then', - ` ${emitMarkerCommand(hookPostedMarker)}`, - ' fi', - 'fi' - ].join('\n') + { hook_event_name: 'UserPromptSubmit', prompt }, + 'AGENT_HOOK_POSTED' ) - await waitForTerminalOutput(orcaPage, hookPostedMarker, 20_000) await expect .poll( @@ -292,5 +346,103 @@ test.describe('Localhost SSH', () => { } ) .toBe(true) + + const ctrlPrompt = `orca ssh ctrl-c interrupt ${Date.now()}` + await postCodexHook( + orcaPage, + ptyId, + { hook_event_name: 'UserPromptSubmit', prompt: ctrlPrompt }, + 'AGENT_HOOK_CTRL_WORKING' + ) + await focusTerminal(orcaPage) + await orcaPage.keyboard.press('Control+C') + await orcaPage.waitForTimeout(750) + expect( + await orcaPage.evaluate( + ({ paneKey, prompt, targetId, worktreeId }) => { + const state = window.__store?.getState() + const entry = state?.agentStatusByPaneKey[paneKey] + const events = + ( + window as unknown as { + __sshAgentStatusEvents?: { + prompt?: string + connectionId?: string | null + worktreeId?: string + }[] + } + ).__sshAgentStatusEvents ?? [] + return { + state: entry?.state, + interrupted: entry?.interrupted, + prompt: entry?.prompt, + eventMatched: events.some( + (event) => + event.prompt === prompt && + event.connectionId === targetId && + event.worktreeId === worktreeId + ) + } + }, + { paneKey, prompt: ctrlPrompt, targetId: remote.targetId, worktreeId: remote.worktreeId } + ) + ).toEqual({ + state: 'working', + interrupted: undefined, + prompt: ctrlPrompt, + eventMatched: true + }) + + await postCodexHook( + orcaPage, + ptyId, + { + hook_event_name: 'PreToolUse', + tool_name: 'exec_command', + tool_input: { cmd: '/bin/sleep 90' } + }, + 'AGENT_HOOK_LATE_WORKING' + ) + await expect + .poll( + () => + orcaPage.evaluate( + ({ paneKey }) => { + const entry = window.__store?.getState().agentStatusByPaneKey[paneKey] + return { + state: entry?.state, + interrupted: entry?.interrupted, + prompt: entry?.prompt + } + }, + { paneKey } + ), + { timeout: 5_000, message: 'Late remote working hook did not remain working' } + ) + .toEqual({ state: 'working', interrupted: undefined, prompt: ctrlPrompt }) + + const escapePrompt = `orca ssh escape interrupt ${Date.now()}` + await postCodexHook( + orcaPage, + ptyId, + { hook_event_name: 'UserPromptSubmit', prompt: escapePrompt }, + 'AGENT_HOOK_ESCAPE_WORKING' + ) + await focusTerminal(orcaPage) + await orcaPage.keyboard.press('Escape') + await orcaPage.waitForTimeout(750) + expect( + await orcaPage.evaluate( + ({ paneKey }) => { + const entry = window.__store?.getState().agentStatusByPaneKey[paneKey] + return { + state: entry?.state, + interrupted: entry?.interrupted, + prompt: entry?.prompt + } + }, + { paneKey } + ) + ).toEqual({ state: 'working', interrupted: undefined, prompt: escapePrompt }) }) })