From bb8cacd984dc1ab03612837d2f65d9384ba08dfb Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Mon, 18 May 2026 20:57:51 -0700 Subject: [PATCH] Harden agent interrupt inference Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 355 ++++++++++++ src/main/agent-hooks/server.ts | 20 +- src/main/daemon/daemon-pty-adapter.ts | 4 + src/main/daemon/daemon-pty-router.test.ts | 15 + src/main/daemon/daemon-pty-router.ts | 8 + src/main/ipc/agent-hooks.test.ts | 39 +- src/main/ipc/pty.test.ts | 20 + src/main/ipc/pty.ts | 49 +- src/main/providers/local-pty-provider.ts | 3 + src/main/providers/types.ts | 1 + ...ay-session-agent-hooks.integration.test.ts | 119 ++++ src/main/ssh/ssh-relay-session.ts | 4 + src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + src/relay/agent-hook-server.test.ts | 1 + src/relay/agent-hook-server.ts | 7 +- .../agent-interrupt-inference.test.ts | 141 +++-- .../agent-interrupt-inference.ts | 36 +- .../terminal-pane/pty-connection.test.ts | 526 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 141 ++++- .../terminal-pane/pty-dispatcher.ts | 1 + .../terminal-pane/pty-transport.test.ts | 14 + .../components/terminal-pane/pty-transport.ts | 11 + .../remote-runtime-pty-batching.ts | 13 +- .../remote-runtime-pty-transport.test.ts | 105 ++++ .../remote-runtime-pty-transport.ts | 28 +- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/agent-hook-listener.ts | 16 +- src/shared/agent-hook-relay.ts | 5 + tests/e2e/ssh-localhost.spec.ts | 194 ++++++- 30 files changed, 1742 insertions(+), 138 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 5114eccfea6..77ca94ba0ad 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -360,6 +360,361 @@ describe('AgentHookServer listener replay', () => { } }) + 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) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index a346e420553..d6b8b9fb689 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -113,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 { @@ -180,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 @@ -311,7 +321,7 @@ export class AgentHookServer { // including same-millisecond prompt or agent identity changes. if ( payload.state !== 'working' || - agentType !== request.baselineAgentType || + !equivalentInterruptAgentType(agentType, request.baselineAgentType) || payload.prompt !== request.baselinePrompt || existing.receivedAt !== request.baselineUpdatedAt || existing.stateStartedAt !== request.baselineStateStartedAt || @@ -391,7 +401,9 @@ export class AgentHookServer { payload.payload.state === 'working' && previous.payload.agentType === payload.payload.agentType && previous.payload.prompt === payload.payload.prompt && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + (payload.isReplay === true || + (payload.hasExplicitPrompt !== true && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) ) { return previous } @@ -593,6 +605,8 @@ export class AgentHookServer { worktreeId?: string env?: string version?: string + hasExplicitPrompt?: boolean + isReplay?: boolean payload: unknown }, connectionId: string @@ -668,6 +682,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 96ac6932ad2..ca492a9538f 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -228,6 +228,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.client.notify('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/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/pty.test.ts b/src/main/ipc/pty.test.ts index 97acbe9e0c7..795bb6839f4 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2354,6 +2354,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 a9616bd4e06..7c13afb3d4b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -567,6 +567,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') @@ -1537,7 +1538,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 @@ -1545,9 +1546,51 @@ 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 } - tryGetProviderForPty(args.id)?.write(args.id, args.data) + if (!ptyOwnership.has(args.id)) { + return false + } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return false + } + try { + 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 { + 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 2b84d49c4e2..e3e6d3f3370 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -683,6 +683,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 diff --git a/src/preload/index.ts b/src/preload/index.ts index f85a2e5f5b7..f74c8980ac9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -585,6 +585,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 }) 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 index 83cb4b4f3d7..0ccaa1f2603 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -22,6 +22,18 @@ function makeEntry(overrides: Partial = {}): AgentStatusEntry } } +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() @@ -229,7 +241,26 @@ describe('agent interrupt inference', () => { tracker.dispose() }) - it('cancels when the status disappears during the settle window', () => { + 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() @@ -244,80 +275,46 @@ describe('agent interrupt inference', () => { entry = undefined vi.advanceTimersByTime(500) - expect(inferInterrupt).not.toHaveBeenCalled() + 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({ - key: 'Escape', - ctrlKey: false, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ).toBe(true) - expect( - isPlainEscapeKeyEvent({ - key: 'Escape', - ctrlKey: false, - metaKey: false, - altKey: true, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ).toBe(false) - expect( - isPlainEscapeKeyEvent({ - key: 'c', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ).toBe(false) - expect( - isCtrlCKeyEvent({ - key: 'c', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ).toBe(true) - expect( - isCtrlCKeyEvent({ - key: 'C', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ).toBe(true) - expect( - isCtrlCKeyEvent({ - key: 'c', - ctrlKey: true, - metaKey: true, - altKey: false, - shiftKey: false, - repeat: false - } as KeyboardEvent) - ).toBe(false) - expect( - isCtrlCKeyEvent({ - key: 'c', - ctrlKey: true, - metaKey: false, - altKey: false, - shiftKey: false, - repeat: true - } as KeyboardEvent) - ).toBe(false) + 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 index 68c3c460f51..8b316f71da4 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -11,13 +11,14 @@ 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) => void + inferInterrupt: (request: AgentInterruptInferenceRequest) => boolean | Promise | void now?: () => number setTimer?: (callback: () => void, ms: number) => ReturnType clearTimer?: (timer: ReturnType) => void @@ -121,28 +122,31 @@ export function createAgentInterruptInference({ } } - const flushPending = (): void => { + const flushPending = (): boolean | Promise => { const baseline = pendingBaseline pendingTimer = null pendingBaseline = null if (!baseline) { - return + 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) + 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 + return false + } + if (!entry && now() - baseline.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return false } - inferInterrupt({ + const result = inferInterrupt({ paneKey, baselineUpdatedAt: baseline.updatedAt, baselineStateStartedAt: baseline.stateStartedAt, @@ -151,6 +155,11 @@ export function createAgentInterruptInference({ intent: baseline.intent, ...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {}) }) + return result ?? true + } + + const flushPendingFromTimer = (): void => { + void flushPending() } return { @@ -181,8 +190,9 @@ export function createAgentInterruptInference({ clearPendingTimer() } pendingBaseline = baseline - pendingTimer = setTimer(flushPending, AGENT_INTERRUPT_SETTLE_MS) + 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 4c36c12db36..e610640bbce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -80,6 +80,7 @@ type MockTransport = { ) => unknown } sendInput: ReturnType + sendInputAccepted?: ReturnType resize: ReturnType getPtyId: ReturnType } @@ -180,7 +181,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 }), @@ -195,6 +196,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) { @@ -210,6 +214,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() })) } @@ -267,6 +272,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) => { @@ -329,6 +382,9 @@ describe('connectPanePty', () => { notifications: { dispatch: vi.fn().mockResolvedValue({ delivered: true }), playSound: vi.fn().mockResolvedValue({ played: true }) + }, + agentStatus: { + inferInterrupt: vi.fn().mockResolvedValue(false) } } } @@ -414,6 +470,186 @@ 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('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') @@ -508,6 +744,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 = { @@ -520,10 +758,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 @@ -539,9 +790,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 () => { @@ -670,6 +1062,136 @@ describe('connectPanePty', () => { expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) + it('flushes pending interrupt inference before OSC 133 can drop the status row', async () => { + const { connectPanePty } = await import('./pty-connection') + + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + 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: [] + } + } + } + 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).not.toHaveBeenCalled() + }) + + 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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f3e979b8b0a..88adacd6809 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -34,6 +34,7 @@ import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from './agent-interrupt-inference' +import type { AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -193,35 +194,112 @@ 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 commandLifecycle = createTerminalCommandLifecycle({ - onCommandFinished: () => { - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - // 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) - } - } - }) - commandLifecycle.attachXtermConsumer(pane.terminal) const interruptInference = createAgentInterruptInference({ paneKey: cacheKey, getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], inferInterrupt: (request) => { - void window.api.agentStatus.inferInterrupt(request).catch((err) => { + return window.api.agentStatus.inferInterrupt(request).catch((err) => { console.warn('[agent-interrupt] inferInterrupt failed:', err) + return false }) } }) + const dropCommandFinishedStatusIfUnchanged = (entry: AgentStatusEntry | undefined): void => { + if (!entry) { + return + } + const state = useAppStore.getState() + const current = state.agentStatusByPaneKey[cacheKey] + if ( + !current || + current.state !== entry.state || + current.prompt !== entry.prompt || + current.updatedAt !== entry.updatedAt || + current.stateStartedAt !== entry.stateStartedAt || + current.agentType !== entry.agentType + ) { + 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 observeSentTerminalInputIntent = ( + data: string, + intent = pendingTerminalInputIntent + ): void => { + if (intent && inputMatchesIntent(intent, data)) { + interruptInference.observeInputIntent(intent) + } + } + 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) { + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + if (!applied) { + dropCommandFinishedStatusIfUnchanged(entry) + } + }) + 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. + dropCommandFinishedStatusIfUnchanged(entry) + } + }) + commandLifecycle.attachXtermConsumer(pane.terminal) const onTerminalKeyDown = (event: KeyboardEvent): void => { if (isPlainEscapeKeyEvent(event)) { - interruptInference.observeInputIntent('plain-escape') + setPendingTerminalInputIntent('plain-escape') return } if (isCtrlCKeyEvent(event)) { - interruptInference.observeInputIntent('ctrl-c') + if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) { + return + } + setPendingTerminalInputIntent('ctrl-c') } } // Why: infer only from focused xterm key events. Raw PTY bytes cannot @@ -584,6 +662,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 @@ -594,6 +673,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 @@ -602,7 +682,32 @@ export function connectPanePty( // auto-replies never count as interaction. deps.clearTerminalTabUnread(deps.tabId) deps.clearWorktreeUnread(deps.worktreeId) - transport.sendInput(data) + const intent = pendingTerminalInputIntent + if (intent && transport.sendInputAccepted) { + clearPendingTerminalInputIntent() + const writePromise = transport + .sendInputAccepted(data) + .then((accepted) => { + if (accepted) { + observeSentTerminalInputIntent(data, intent) + } + }) + .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 }) => { @@ -1454,6 +1559,8 @@ export function connectPanePty( if (terminalKeyTargetSupportsEvents) { terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) } + clearPendingTerminalInputIntent() + pendingTerminalInputWrite = null interruptInference.dispose() // Why: actively resolve any in-flight passphrase-gate waits so their // zustand subscribers + async IIFEs don't hang for the rest of the 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 765eb6fec5a..73b94f9c8a8 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('normalizes bare LF input to carriage returns before writing to the remote PTY', 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 087f2b41cce..f52dae6ad81 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' @@ -212,6 +213,29 @@ export function createRemoteRuntimePtyTransport( } } + async function sendInputAcceptedToRuntime(data: string): Promise { + const targetHandle = handle + if (!connected || !targetHandle) { + return false + } + const normalized = normalizeRemoteTerminalInput(data) + if (!normalized) { + return true + } + const text = `${inputBatcher.takePending()}${normalized}` + 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) { @@ -465,6 +489,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 e47a3278ef9..bf98571f456 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1071,6 +1071,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..fcd0788ff81 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 } @@ -1973,7 +1978,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/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 }) }) })