diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index 128f163c5b3..5ae2adb49d7 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -359,7 +360,8 @@ describe('registerPtyHandlers', () => { const exitListeners = new Set<(payload: { id: string; code: number }) => void>() const runtime = { setPtyController: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } setLocalPtyProvider({ spawn: vi.fn(), @@ -393,15 +395,14 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { - markReversibleStops: (ptyIds: readonly string[]) => () => void stopAndWait: (ptyId: string) => Promise } - const release = controller.markReversibleStops(['local-pty']) + const settleStop = runtime.intentionalPtyStops.mark('local-pty', 'reversible', null) const stopPromise = controller.stopAndWait('local-pty') await vi.advanceTimersByTimeAsync(1_200) await expect(stopPromise).resolves.toBe(true) - release() + settleStop(true) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index a1ced7904f8..50558a0f870 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -3,6 +3,7 @@ import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harne import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -161,7 +162,8 @@ function installRestartHarness( seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } return { providerSpawn, shutdown, store, runtime, control } } diff --git a/src/main/ipc/pty-ssh-undelivered-kill.test.ts b/src/main/ipc/pty-ssh-undelivered-kill.test.ts index bf231fdc235..32f4da8356f 100644 --- a/src/main/ipc/pty-ssh-undelivered-kill.test.ts +++ b/src/main/ipc/pty-ssh-undelivered-kill.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors' import { registerPtyHandlers, @@ -79,7 +80,8 @@ function installController(handlers: Map) { setPtyController: vi.fn(), markPtyStopRequested: vi.fn(), markPtyLivenessUnverifiable: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } handlers.clear() return { runtime } @@ -91,7 +93,6 @@ describe('undelivered SSH stops', () => { function install(store: ReturnType): { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean runtime: ReturnType['runtime'] } { @@ -108,13 +109,11 @@ describe('undelivered SSH stops', () => { const controller = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean } return { kill: controller.kill, stopAndWait: controller.stopAndWait, - markReversibleStops: controller.markReversibleStops, recordUnconfirmedStop: controller.recordUnconfirmedStop, runtime } @@ -302,15 +301,41 @@ describe('undelivered SSH stops', () => { ) setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') - const { kill, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { kill, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { kill(SCOPED_PTY_ID) await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) + unregisterSshPtyProvider('ssh-1') + deletePtyOwnership(SCOPED_PTY_ID) + } + }) + + it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => { + const store = createKillStore() + registerSshPtyProvider( + 'ssh-1', + sshProviderStub(async () => { + throw new Error('socket closed') + }) + ) + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') + const { kill, runtime } = install(store) + const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) + const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null) + + try { + kill(SCOPED_PTY_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() + } finally { + settleRestart(false) + settleSleep(false) unregisterSshPtyProvider('ssh-1') deletePtyOwnership(SCOPED_PTY_ID) } @@ -379,15 +404,15 @@ describe('undelivered SSH stops', () => { restorePtyIncarnation('local-pty', 'inc-h') setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-i') - const { recordUnconfirmedStop, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { recordUnconfirmedStop, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { expect(recordUnconfirmedStop('local-pty')).toBe(false) expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(false) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) deletePtyOwnership('local-pty') deletePtyOwnership(SCOPED_PTY_ID) } diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index a7575f7c2e6..ad144b4d587 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -7,57 +7,8 @@ import { allocatePtyLifecycleSequence } from '../host-env/types' import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' import { getRendererInFlightCharsForPty } from './accounting' import { clearFlushTimerIfIdle } from './flush' -import { ptyIncarnationById } from '../provider/ownership-state' import type { PtyIpcSession } from '../session' -export type ReplacedPtyStop = { - incarnationId: string | undefined - expiryTimer?: NodeJS.Timeout -} - -/** Labels the exit of a PTY that main stops so a new process can take its pane. Settle with - * whether the stop succeeded; a failed stop leaves no label behind. */ -export function markReplacedPtyStop( - session: PtyIpcSession, - id: string -): (stopped: boolean) => void { - clearTimeout(session.replacedPtyStopsById.get(id)?.expiryTimer) - const mark: ReplacedPtyStop = { incarnationId: ptyIncarnationById.get(id) } - session.replacedPtyStopsById.set(id, mark) - return (stopped) => { - if (session.replacedPtyStopsById.get(id) !== mark) { - return - } - if (!stopped) { - session.replacedPtyStopsById.delete(id) - return - } - // Why a window: an SSH exit can reach the renderer after the stop settles; bound it like a synthetic kill. - mark.expiryTimer = setTimeout(() => { - if (session.replacedPtyStopsById.get(id) === mark) { - session.replacedPtyStopsById.delete(id) - } - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - mark.expiryTimer.unref?.() - } -} - -function consumeReplacedPtyStop( - session: PtyIpcSession, - payload: { id: string; incarnationId?: string } -): boolean { - const mark = session.replacedPtyStopsById.get(payload.id) - if ( - !mark || - (mark.incarnationId && payload.incarnationId && mark.incarnationId !== payload.incarnationId) - ) { - return false - } - clearTimeout(mark.expiryTimer) - session.replacedPtyStopsById.delete(payload.id) - return true -} - export function rememberSyntheticKillExit( session: PtyIpcSession, id: string, @@ -202,12 +153,12 @@ export function finalizePtyExitForRenderer( session.schedulePendingDataAfterCreditReport(true) } } + const intentionalStops = + session.runtime?.intentionalPtyStops?.claimExit(payload.id, payload.incarnationId) ?? [] session.mainWindow.webContents.send('pty:exit', { ...payload, - ...(session.reversibleStopOwnersByPtyId.has(payload.id) - ? { preserveRendererBinding: true } - : {}), - ...(consumeReplacedPtyStop(session, payload) ? { replacedByRestart: true } : {}) + ...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}), + ...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {}) }) } diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 8f73e72aa59..10a0ffa476e 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -1,10 +1,11 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' -import { ptyOwnership } from '../provider/ownership-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry' import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill' @@ -31,27 +32,49 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { ) } -/** Stops a pane's PTY for the spawn replacing it. `markReplaced` labels the exit so the renderer - * reads it as a handoff, not the pane dying; a failed stop removes the label with nothing sent. */ -export async function stopReplacedPanePty( - deps: PtyKillIpcDeps, - id: string, - markReplaced: (id: string) => (stopped: boolean) => void -): Promise { - const settle = markReplaced(id) - try { - await stopRendererOwnedPty(deps, { id }) - } catch (err) { - settle(false) - throw err - } - settle(true) +/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the + * renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */ +export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise { + await stopRendererOwnedPtyAs(deps, { id }, 'replaced') } /** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */ export async function stopRendererOwnedPty( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } +): Promise { + // Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding. + await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null) +} + +async function stopRendererOwnedPtyAs( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean }, + intentionalStop: TerminalIntentionalStopKind | null +): Promise { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const settleStop = intentionalStop + ? deps.runtime?.intentionalPtyStops?.mark( + args.id, + intentionalStop, + ptyIncarnationById.get(args.id) ?? null + ) + : undefined + let stopped = false + try { + await stopRendererOwnedPtyProcess(deps, args) + stopped = true + } finally { + settleStop?.(stopped) + } +} + +async function stopRendererOwnedPtyProcess( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean } ): Promise { const { store, @@ -61,10 +84,6 @@ export async function stopRendererOwnedPty( rememberSyntheticKillExit, sendPtyExitToRenderer } = deps - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } runtime?.markPtyStopRequested?.(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 5d9ad086920..3740e1489a0 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -83,6 +83,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

) { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops: stops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { + runtime, + store: { persistPtyBinding }, + options: {}, + sendPtySpawnedToRenderer: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above, and the store only for its binding save. + const deps = ports as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1', + cols: 120, + rows: 40 + }) + ctx.validatedLeafId = 'leaf-1' + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } + const outcome = await commitPtyIpcSpawn(ctx).then( + () => 'committed', + () => 'discarded' + ) + return { outcome, facts, stops } +} + +describe('renderer spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a committed spawn and lets it supersede a landed stop no exit pinned', async () => { + const { outcome, facts, stops } = await commit(vi.fn().mockResolvedValue(true)) + + expect(outcome).toBe('committed') + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(true) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + const { outcome, facts, stops } = await commit(persistPtyBinding) + + expect(outcome).toBe('discarded') + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts index ceeb5127b7d..4afd2b008f1 100644 --- a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts +++ b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts @@ -68,7 +68,7 @@ describe('chunked pty write yield', () => { }) as typeof setImmediate) const outcome = await Promise.race([ - createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }), + createWriteInput()({ inputKind: 'driving', id: PTY_ID, data: THREE_CHUNK_INPUT }), afterImmediateTurns(50) ]) @@ -88,6 +88,7 @@ describe('chunked pty write yield', () => { const immediate = vi.spyOn(globalThis, 'setImmediate') const outcome = createWriteInput()({ + inputKind: 'driving', id: PTY_ID, data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) }) diff --git a/src/main/ipc/pty/ipc/write-input-user-input.test.ts b/src/main/ipc/pty/ipc/write-input-user-input.test.ts new file mode 100644 index 00000000000..d4f20f00a68 --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-user-input.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-user-input' + +const { provider } = vi.hoisted(() => ({ + provider: { write: vi.fn(), hasPty: vi.fn(() => true) } +})) + +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined) +})) + +function createWriteInput(facts: TerminalRunFactsRegister) { + const runtime = { getDriver: () => ({ kind: 'desktop' }), terminalRunFacts: facts } + const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: write input reads only getDriver and terminalRunFacts from the runtime, and isDestroyed/webContents from the window. + return createPtyWriteInput({ mainWindow: mainWindow as never, runtime: runtime as never }) +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + provider.write.mockReset() +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) +}) + +describe('renderer PTY writes: input kind', () => { + it.each(['writePtyInput', 'writePtyInputAccepted'] as const)( + '%s records driving input before the provider write', + async (writer) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + const recordedAtWrite: (number | null)[] = [] + provider.write.mockImplementation(() => { + recordedAtWrite.push(facts.read(PTY_ID, 'inc-1').firstUserInputAt) + }) + + await createWriteInput(facts)[writer]({ id: PTY_ID, data: 'exit\r', inputKind: 'driving' }) + + expect(recordedAtWrite).toEqual([expect.any(Number)]) + } + ) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[3;4R'], + ['a driving write that is only a reply', 'driving', '\x1b[3;4R'], + ['a driving write that is only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', async (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + + await createWriteInput(facts).writePtyInput({ id: PTY_ID, data, inputKind }) + + expect(provider.write).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 77b27ef16f0..2f8ea72cd9b 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -9,6 +9,7 @@ import { } from '../../../../shared/terminal-input' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' export function isMainWindowPtyIpcEvent( @@ -25,7 +26,7 @@ export function isMainWindowPtyIpcEvent( ) } -export type PtyWritePayload = { id: string; data: string } +export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { @@ -148,6 +149,12 @@ export function createPtyWriteInput(deps: { const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => isMainWindowPtyIpcEvent(event, mainWindow) + const noteRendererPtyInput = (args: PtyWritePayload): void => { + lastInputAtByPty.set(args.id, performance.now()) + interactiveOutputCharsByPty.set(args.id, 0) + runtime?.terminalRunFacts?.recordInput(args.id, args.inputKind, args.data) + } + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. if (runtime?.getDriver(args.id).kind === 'mobile') { @@ -158,9 +165,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false @@ -180,9 +185,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index aaace9fac37..5c7c897474a 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -17,7 +17,6 @@ import { stopReplacedPanePty, type PtyKillIpcDeps } from './ipc/renderer-kill' -import { markReplacedPtyStop } from './delivery/exit' import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' @@ -237,7 +236,6 @@ export function registerPtyHandlers( options, trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, retiredRejectedPtyIds: session.retiredRejectedPtyIds, - reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, mainWindow, transitionSpawnHiddenRendererPtyDeliveryState: session.transitionSpawnHiddenRendererPtyDeliveryState, @@ -275,8 +273,7 @@ export function registerPtyHandlers( trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery, - stopReplacedPty: (id) => - stopReplacedPanePty(killDeps, id, (ptyId) => markReplacedPtyStop(session, ptyId)) + stopReplacedPty: (id) => stopReplacedPanePty(killDeps, id) }) installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts index 513d969f110..a5d19f020fc 100644 --- a/src/main/ipc/pty/register-without-renderer.test.ts +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -128,8 +128,8 @@ describe('PTY registration without renderer delivery', () => { throw new Error('missing runtime PTY controller') } - expect(controller.write('daemon-pty', 'local input')).toBe(true) - expect(controller.write(remoteId, 'remote input')).toBe(true) + expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true) + expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true) await controller.clearBuffer?.('daemon-pty') await controller.clearBuffer?.(remoteId) await expect(controller.attach?.('daemon-pty')).resolves.toBe(true) @@ -147,7 +147,7 @@ describe('PTY registration without renderer delivery', () => { expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty') unregisterSshPtyProvider('ssh-a') - expect(controller.write(remoteId, 'disconnected input')).toBe(false) + expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false) await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull() expect(local.write).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 8f20245505c..e1bfd5d7f10 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -82,7 +82,6 @@ export type PtyRuntimeControllerDeps = { } trustedTerminalHandleEnv: Set retiredRejectedPtyIds: Map - reversibleStopOwnersByPtyId: Map mainWindow?: PtyRendererDelivery transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void syncPtyBackgroundedDelivery?: (id: string, caller: string) => void diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 06ce1891b69..75ff168a646 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -4,7 +4,6 @@ import type { PtyRuntimeControllerDeps } from './controller-deps' import { spawnPtyFromRuntimeController } from './spawn' import { killPtyFromRuntimeController, - markReversibleStopsFromRuntimeController, retireRejectedPtyFromRuntimeController, stopAndWaitPtyFromRuntimeController } from './kill' @@ -45,9 +44,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), - writeWithSettlement: (ptyId, data) => - writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }), + write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind), + writeWithSettlement: (ptyId, data, inputKind) => + writePtyFromRuntimeController(deps, ptyId, data, inputKind, { waitForSettlement: true }), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own @@ -57,13 +56,12 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), retireRejectedPty: (ptyId, stopConfirmed) => retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), - markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), recordUnconfirmedStop: (ptyId) => recordUnconfirmedExplicitSshStop({ store: deps.store, ptyId, - reversible: deps.reversibleStopOwnersByPtyId.has(ptyId) + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false }), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 98bdc1e97ae..edbd12b0b18 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -19,8 +19,7 @@ export function killPtyFromRuntimeController( rememberSyntheticKillExit, sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds, - reversibleStopOwnersByPtyId + retiredRejectedPtyIds } = deps runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) @@ -31,7 +30,7 @@ export function killPtyFromRuntimeController( store, ptyId, connectionId, - reversible: reversibleStopOwnersByPtyId.has(ptyId), + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false, incarnationId }) } @@ -184,31 +183,6 @@ export function retireRejectedPtyFromRuntimeController( }) } -export function markReversibleStopsFromRuntimeController( - deps: PtyRuntimeControllerDeps, - ptyIds: readonly string[] -): () => void { - const { reversibleStopOwnersByPtyId } = deps - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } -} - /** * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`. * diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index f03892a6e31..5fec2c25e26 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -12,16 +12,28 @@ import { writeUnverifiable, type WriteSettlement } from '../../../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + +type RuntimeWriteDeps = Pick -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind +): boolean +export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, + ptyId: string, + data: string, + inputKind: TerminalInputKind, options: { waitForSettlement: true } ): WriteSettlement | Promise export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind, options?: { waitForSettlement: true } ): boolean | WriteSettlement | Promise { let provider: IPtyProvider @@ -36,6 +48,7 @@ export function writePtyFromRuntimeController( if (!provider.writeWithSettlement) { return writeRefused('provider_cannot_settle') } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.writeWithSettlement(ptyId, data) } catch { @@ -43,6 +56,7 @@ export function writePtyFromRuntimeController( return writeUnverifiable('provider_threw_after_handoff', true) } } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.write(ptyId, data) !== false } catch { diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts new file mode 100644 index 00000000000..5701b9f82c9 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id' +import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptySizes } from '../delivery/visibility-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +const PTY_ID = 'orca-pty-run-facts' +const INCARNATION_ID = 'inc-run-facts' + +const ADOPTED = { + disposition: 'adopted', + owner: { + claim: { kind: 'terminal' }, + generation: 'g1', + phase: 'live', + ptyId: PTY_ID, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + } +} + +async function commit( + result: Record, + facts = new TerminalRunFactsRegister(), + intentionalPtyStops = new TerminalIntentionalStops(), + prepare?: (ctx: ReturnType) => void +) { + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { runtime, store: undefined, options: {}, sendPtySpawnedToRenderer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above; store-less deps skip every persistence branch. + const deps = ports as unknown as PtyRuntimeControllerDeps + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worktree-less spawn needs only its grid. + const args = { cols: 120, rows: 40 } as unknown as RuntimePtySpawnArgs + const ctx = createRuntimePtySpawnState(deps, args) + const spawned = { id: PTY_ID, incarnationId: INCARNATION_ID, ...result } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each case sets the spawn-result fields the commit reads. + ctx.result = spawned as unknown as typeof ctx.result + prepare?.(ctx) + await commitRuntimePtySpawn(ctx) + return facts.read(PTY_ID, INCARNATION_ID) +} + +describe('runtime spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a new process as a fresh spawn', async () => { + expect((await commit({})).freshSpawn).toBe(true) + }) + + it('never reads an SSH adoption that omits isReattach as fresh', async () => { + expect((await commit({ agentSessionEnsure: ADOPTED })).freshSpawn).toBe(false) + }) + + it('never reads a cold restore as fresh', async () => { + const coldRestore = { scrollback: 'prior output', cwd: '/tmp' } + + expect((await commit({ coldRestore })).freshSpawn).toBe(false) + }) + + it('keeps a process run facts when the same incarnation commits again', async () => { + const facts = new TerminalRunFactsRegister() + await commit({}, facts) + facts.recordInput(PTY_ID, 'driving', 'ls\r', 100) + + expect(await commit({ isReattach: true }, facts)).toEqual({ + freshSpawn: true, + firstUserInputAt: 100 + }) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('lets a committed $spawn supersede a landed stop that no exit pinned', async ({ result }) => { + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await commit(result, new TerminalRunFactsRegister(), stops) + + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + + await expect( + commit({}, facts, stops, (ctx) => { + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.hostSessionBinding = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store. + store: { persistPtyBinding } as unknown as NonNullable< + typeof ctx.hostSessionBinding + >['store'], + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1' + } + }) + ).rejects.toThrow() + + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('records nothing for a $spawn that exited during start', async ({ result }) => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await expect( + commit(result, facts, stops, (ctx) => { + ctx.args.worktreeId = 'wt-1' + ctx.deps.runtime!.registerPty = vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }) + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 856039e7d5a..95994e0effc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -92,6 +92,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (rejectedRegistration) { await rejectedRegistration } + // Why here: an adoption returns before the commit site below. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) if (ctx.result.incarnationId) { @@ -187,6 +192,12 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + // Why after registration: a spawn discarded for a failed save or rejected for exiting during + // start must not record facts or end a stop. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 46f16081efa..a299327fc3f 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -43,7 +43,6 @@ function makeDeps(): PtyRuntimeControllerDeps { finishPtyShutdown, trustedTerminalHandleEnv: new Set(), retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest. mainWindow: {} as BrowserWindow } diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index e5c59d9b81a..be666fc6248 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -10,7 +10,6 @@ import type { PtyRendererDeliveryStateReport } from '../../../shared/pty-renderer-delivery-health' import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' -import type { ReplacedPtyStop } from './delivery/exit' import { PtyProducerFlowController } from '../pty-producer-flow-control' import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' @@ -108,8 +107,6 @@ export type PtyIpcSession = { string, { cleanupTimer: NodeJS.Timeout; incarnationId: string | undefined } > - reversibleStopOwnersByPtyId: Map - replacedPtyStopsById: Map retiredRejectedPtyIds: Map pendingSerializeRequests: Map< string, @@ -238,8 +235,6 @@ export function createPtyIpcSession(args: { sourceCreditPendingPtys: new Set(), backgroundedDeliverySyncByPty: new Map(), syntheticKillExitPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), - replacedPtyStopsById: new Map(), retiredRejectedPtyIds: new Map(), pendingSerializeRequests: new Map(), canSendPtyDataToRenderer: unsetSessionFn, diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 58bed424a51..903d93c4967 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -10,11 +10,13 @@ export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw */ export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown' +export type PtySpawnCommitOrigin = Extract + /** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */ export function spawnCommitBindingOrigin( commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } }, expectedSourceBinding?: unknown -): PtyBindingOrigin { +): PtySpawnCommitOrigin { if (expectedSourceBinding !== undefined) { return 'split' } diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index b1d185c38b1..2777fdfde4b 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -37,7 +37,6 @@ export function createPtySpawnCommitDependencies( sendPtyExitToRenderer: unexpectedPreflight, finishPtyShutdown: unexpectedPreflight, retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), get mainWindow() { return unexpectedPreflight() } diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index ee6e7a5b9d0..7f196c9eb81 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -194,10 +194,14 @@ describe('terminal.sendText explicit worktree routing', () => { { includeVisualLayouts: false } ) expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) - expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { - text: 'echo hi', - enter: true - }) + expect(delegate.sendTerminal).toHaveBeenCalledWith( + terminalId, + { + text: 'echo hi', + enter: true + }, + { inputKind: 'driving' } + ) expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! ) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..731934d5595 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -19,7 +20,8 @@ export type PluginRuntimeDelegate = { ): Promise<{ terminals: { handle: string; title: string | null }[] }> sendTerminal( handle: string, - action: { text?: string; enter?: boolean } + action: { text?: string; enter?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise<{ accepted: boolean }> dispatchPluginNotification(input: { pluginId: string @@ -59,7 +61,7 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) + const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' }) return { accepted: result.accepted } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), diff --git a/src/main/runtime/agent-prompt-line-settle.test.ts b/src/main/runtime/agent-prompt-line-settle.test.ts index 45d8bca53a6..4ac0d9648ee 100644 --- a/src/main/runtime/agent-prompt-line-settle.test.ts +++ b/src/main/runtime/agent-prompt-line-settle.test.ts @@ -37,7 +37,7 @@ describe('agent prompt line-settle scheduling', () => { () => undefined, 'antigravity' ) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(submitDelayMs - 1) diff --git a/src/main/runtime/agent-prompt-receipt-correlation.test.ts b/src/main/runtime/agent-prompt-receipt-correlation.test.ts index e6b6d7f2793..77e8967b6d3 100644 --- a/src/main/runtime/agent-prompt-receipt-correlation.test.ts +++ b/src/main/runtime/agent-prompt-receipt-correlation.test.ts @@ -34,6 +34,7 @@ describe('agent prompt receipt correlation', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-first', observationTimeoutMs: 0 @@ -41,6 +42,7 @@ describe('agent prompt receipt correlation', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-second', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts index c893623a18a..3d0c317a31b 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -102,7 +102,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -130,6 +132,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'antigravity-pre-invocation', observationTimeoutMs: 20_000 @@ -156,7 +159,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { stateStartedAt: 1_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -171,6 +176,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-first', observationTimeoutMs: 0 @@ -193,6 +199,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-second', observationTimeoutMs: 500 @@ -219,7 +226,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') await vi.advanceTimersByTimeAsync(0) @@ -256,6 +265,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -291,7 +301,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -318,9 +330,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -331,7 +343,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -351,8 +365,10 @@ describe('agent prompt submission runtime hook and generation cases', () => { } }) - const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') - const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { inputKind: 'driving' }) + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await Promise.all([first, second]) @@ -373,6 +389,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-first', observationTimeoutMs: 0 @@ -380,6 +397,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-second', observationTimeoutMs: 0 @@ -418,6 +436,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + inputKind: 'driving', beforeWrite: async () => { firstWriteReached() await firstGate @@ -430,7 +449,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { 0 ) - const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(replacement).resolves.toMatchObject({ accepted: true }) expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) @@ -444,6 +465,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -466,6 +488,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -483,6 +506,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 873863ada75..700445483a9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -43,7 +43,9 @@ describe('agent prompt submission runtime', () => { } ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -59,7 +61,9 @@ describe('agent prompt submission runtime', () => { } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -75,7 +79,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -93,7 +99,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -109,7 +117,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -122,9 +132,9 @@ describe('agent prompt submission runtime', () => { const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -140,9 +150,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -155,9 +165,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -175,7 +185,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -197,9 +209,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -214,7 +226,9 @@ describe('agent prompt submission runtime', () => { text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n' }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -235,7 +249,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -257,7 +273,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', output, Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -271,6 +289,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -291,6 +310,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -319,9 +339,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -369,7 +389,9 @@ describe('agent prompt submission runtime', () => { ) vi.setSystemTime(2_000) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -389,7 +411,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -408,7 +432,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -432,7 +458,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -453,6 +481,7 @@ describe('agent prompt submission runtime', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-output-only', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index f6298df8b17..3bc936c78a5 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -111,7 +111,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor('review this', 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -131,7 +133,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { // Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms. const delayMs = submitDelayFor(prompt, 'win32') expect(delayMs).toBeLessThan(700) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs) @@ -146,7 +148,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() const prompt = 'y'.repeat(320_000) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Every byte is already handed to node-pty here -- the hazard is that the *host* is @@ -167,6 +169,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime() const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -186,7 +189,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -215,7 +220,9 @@ describe('agent prompt submit delay follows the execution host', () => { patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' }) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -235,7 +242,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('win32') const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(clientDelayMs) @@ -255,7 +264,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('linux') const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -277,7 +288,9 @@ describe('agent prompt submit delay follows the execution host', () => { }) registerSshRemotePlatform(undefined) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -341,7 +354,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000)) + const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000), { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while @@ -370,7 +385,7 @@ describe('agent prompt render gate on a ConPTY host', () => { markerDelayMs: ingestMs - 1_000, noiseUntilMs: ingestMs + 20_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1) @@ -387,7 +402,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -409,7 +426,11 @@ describe('plain terminal send suffix delay', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() - const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true }) + const send = runtime.sendTerminal( + handle, + { text: 'z'.repeat(320_000), enter: true }, + { inputKind: 'driving' } + ) // Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here. await vi.advanceTimersByTimeAsync(3_342) @@ -429,7 +450,7 @@ describe('plain terminal send suffix delay', () => { const send = runtime.sendTerminal( handle, { text: 'z'.repeat(320_000), enter: true }, - { signal: controller.signal } + { inputKind: 'driving', signal: controller.signal } ) const rejected = expect(send).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index a9a3b4484a8..eb4acb14d5e 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -226,7 +226,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l')) if (text) { - await api.sendTerminal(pane.handle, { text }) + await api.sendTerminal(pane.handle, { text }, { inputKind: 'driving' }) } return '' } diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts index 10234f5226d..bddfad17d17 100644 --- a/src/main/runtime/claude-agent-teams-types.ts +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalShow, RuntimeTerminalSplit } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type AgentTeamsTmuxCompatRequest = { teamId: string @@ -43,7 +44,8 @@ export type AgentTeamsTerminalApi = { readTerminal(handle: string, opts?: { limit?: number }): Promise sendTerminal( handle: string, - action: { text?: string; enter?: boolean; interrupt?: boolean } + action: { text?: string; enter?: boolean; interrupt?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise focusTerminal(handle: string): Promise closeTerminal(handle: string): Promise diff --git a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts index f433c75793d..7cb440b4bac 100644 --- a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts +++ b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts @@ -182,6 +182,7 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca try { await assertTerminalInputWithinLimitWithYield(data) await this.writeTerminalInputChunks(ptyId, data, { + inputKind: 'driving', // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 391b55ac5b0..3c9c8b7eab9 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-termi import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { assertTerminalInputWithinLimitWithYield, @@ -103,7 +104,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string - } = {} + inputKind: TerminalInputKind + } ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -152,7 +154,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso async sendTerminalAgentPrompt( handle: string, prompt: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise { // Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup. const payloadFor = (ptyId: string): string => { diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index b9f35478505..dbdf4f1c37a 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -45,7 +45,7 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) + this.ptyController?.write(ptyId, reply, 'query-reply') } } }) diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 55c6a8dad5e..2cd1cc69c00 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -137,7 +137,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv let settlesInEnterCallback = false try { const payload = formatMessagePointer(unread.length, mailboxHandle) - const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false + const wrote = this.ptyController?.write(deliveryPtyId, payload, 'driving') ?? false if (!wrote) { return } @@ -171,7 +171,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - this.ptyController?.write(deliveryPtyId, '\r') + this.ptyController?.write(deliveryPtyId, '\r', 'driving') } catch { // Terminal may have closed during the delay; mail remains queued for check. } finally { diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index d9d6b2acfb9..7f42304eb8b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -36,7 +36,7 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt this.ptyController!.hasChildProcesses?.(ptyId) ?? Promise.resolve(false), subscribeToData: (ptyId, listener) => this.subscribeToTerminalData(ptyId, listener), readRecentOutput: (ptyId) => this.recentPtyOutputById.get(ptyId)?.read(), - write: (ptyId, data) => this.ptyController?.write(ptyId, data) + write: (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) } } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 99fd88373fa..52b7024b818 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -117,10 +117,9 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null ) } - const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) - const preservesIntentionalHandlelessSurface = - this.intentionalHandlelessPtyStops.has(ptyId) && - (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) + // Why both kinds: a sleep keeps its wake hint, and a restart's replacement takes the pane. + const preservesIntentionallyStoppedSurface = + this.intentionalPtyStops.claimExit(ptyId, exitIncarnationId ?? pty?.incarnationId).length > 0 advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) @@ -217,7 +216,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.pruneDisconnectedPtyTranscript(pty) } let retirement: Promise | undefined - if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { + if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index de98831cdd5..b09ca3b7b8d 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -146,6 +146,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.invalidatePtyIncarnationHandle(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index ad7c81f2e87..04cb91e0281 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -69,7 +69,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O protected writeTerminalInputChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 49d55d96c17..6acf8ba325f 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -100,7 +100,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt return await this.claudeAgentTeams.handleTmuxCompat(request, { splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), readTerminal: (handle, opts) => this.readTerminal(handle, opts), - sendTerminal: (handle, action) => this.sendTerminal(handle, action), + sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options), focusTerminal: (handle) => this.focusTerminal(handle), closeTerminal: (handle) => this.closeTerminal(handle), showTerminal: (handle) => this.showTerminal(handle) diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index bfab8f0ba9a..8e9ad65dc32 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -123,9 +123,9 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } - if (this.ptyController?.write(pty.ptyId, command)) { + if (this.ptyController?.write(pty.ptyId, command, 'launch')) { // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') + this.ptyController.write(pty.ptyId, '\r', 'launch') this.noteTerminalSpawnCommand(pty.ptyId, command) } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index e4f38e058d3..4b252d57fef 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -45,6 +45,8 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { TerminalIntentionalStops } from './terminal-intentional-stops' +import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' import type { TuiIdleEvidenceSource } from './tui-idle-evidence' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, @@ -233,9 +235,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingPtyRegistrationIncarnations = new Map() - // Why: exact-stop is the current sleep transaction boundary; its exit must - // leave the renderer's intentional sleeping surface available for wake. - protected intentionalHandlelessPtyStops = new Map() + // Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it. + readonly intentionalPtyStops = new TerminalIntentionalStops() + + readonly terminalRunFacts = new TerminalRunFactsRegister() + + /** Both spawn-commit funnels report each committed process here, once. */ + noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) + this.intentionalPtyStops.noteSpawnCommit(commit.id) + } // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a @@ -322,7 +331,7 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry() protected readonly terminalWriter = new RuntimeTerminalWriter( - (ptyId, data) => this.ptyController?.write(ptyId, data) ?? false, + (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), (ptyId) => this.getPtyAgent(ptyId) ) diff --git a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts index 7d3645473bc..0f0a856bda2 100644 --- a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts +++ b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const pendingPtyIds = new Set() let generation = 0 let fullyCommitted = false - let releaseReversibleRendererStops = (): void => {} + const settleReversibleStops = new Map void>() try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], @@ -148,16 +148,25 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() - releaseReversibleRendererStops = - ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) - const stopResults = await Promise.allSettled( - orderedLivePtyIds.map(async (ptyId) => ({ + for (const ptyId of orderedLivePtyIds) { + settleReversibleStops.set( ptyId, - stopped: await stopAndWait(ptyId, { + this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + ) + } + const stopResults = await Promise.allSettled( + orderedLivePtyIds.map(async (ptyId) => { + const stopped = await stopAndWait(ptyId, { keepHistory: true, deadlineMs: teardownRpcDeadline(sleepDeadline) }) - })) + settleReversibleStops.get(ptyId)?.(stopped) + return { ptyId, stopped } + }) ) const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { const result = stopResults[index] @@ -251,7 +260,9 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi postStopVerified: true } } finally { - releaseReversibleRendererStops() + for (const settleStop of settleReversibleStops.values()) { + settleStop(false) + } if (!fullyCommitted && generation > 0) { const cancelledPtyIds = [...pendingPtyIds].sort() if (cancelledPtyIds.length > 0) { diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index a96ae64e596..d854be6ec68 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -47,18 +47,22 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { - if (opts.keepHistory) { - this.intentionalHandlelessPtyStops.set( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) - } + // Why: exact-stop is the sleep transaction boundary; its exit must leave the sleeping surface for wake. + const settleStop = opts.keepHistory + ? this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + : null + let stopped = false try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) - } + stopped = await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }) } finally { - this.intentionalHandlelessPtyStops.delete(ptyId) + settleStop?.(stopped) + } + if (!stopped) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } stoppedPtyIds.push(ptyId) } diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index bac59e614b2..dd98d82083f 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -159,12 +159,16 @@ describe('runtime terminal handle incarnation fencing', () => { }) expect(replacement?.handle).not.toBe(staleHandle) await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale') - await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow( - 'terminal_handle_stale' - ) + await expect( + runtime.sendTerminal(staleHandle, { text: 'stale input' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_handle_stale') await expect( - runtime.sendTerminal(replacement!.handle, { text: 'replacement input' }) + runtime.sendTerminal( + replacement!.handle, + { text: 'replacement input' }, + { inputKind: 'driving' } + ) ).resolves.toMatchObject({ accepted: true, handle: replacement!.handle diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts index 2501d0141dd..a1bcdf4b287 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts @@ -85,7 +85,7 @@ describe('OrcaRuntimeService', () => { runtime.sendTerminal( terminal.handle, { text: 'notes', enter: true }, - { beforeWrite, afterWrite } + { inputKind: 'driving', beforeWrite, afterWrite } ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 23312f49420..310a1e12c81 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -150,10 +150,14 @@ describe('OrcaRuntimeService', () => { nextCursor: expect.any(String) }) - const send = await runtime.sendTerminal(terminal.handle, { - text: 'continue', - enter: true - }) + const send = await runtime.sendTerminal( + terminal.handle, + { + text: 'continue', + enter: true + }, + { inputKind: 'driving' } + ) expect(send).toMatchObject({ handle: terminal.handle, accepted: true diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts index 27421489aba..a47f037d40e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts @@ -602,7 +602,7 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledTimes(2) expect(write.mock.calls[0][0]).toBe('pty-bare') expect(String(write.mock.calls[0][1])).toMatch(/codex/) - expect(write.mock.calls[1]).toEqual(['pty-bare', '\r']) + expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', 'launch']) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 9145bea46f6..d90dc63031b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -467,7 +467,7 @@ describe('OrcaRuntimeService', () => { }) const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) - await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }) expect(writes).toEqual(['continue', '\r']) }) @@ -490,7 +490,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const prompt = 'line one\nline two\x1b[201~' - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -535,6 +535,7 @@ describe('OrcaRuntimeService', () => { ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE }) await vi.runAllTimersAsync() @@ -601,6 +602,7 @@ describe('OrcaRuntimeService', () => { const assertAuthority = vi.fn() const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', beforeWrite: assertAuthority }) await vi.advanceTimersByTimeAsync(500) @@ -659,7 +661,9 @@ describe('OrcaRuntimeService', () => { 'review this change', agent ) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) if (agent === 'omp') { await sendPromise expect(writes).toEqual([`${buildAgentPromptPasteBytes('review this change')}\r`]) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index 8b27ce9bb5d..fe3687aab0a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -44,7 +44,9 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(1_199) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_500) @@ -78,7 +80,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') @@ -117,7 +121,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'codex' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(8_000) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_599) @@ -159,7 +165,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this // small is already ingested by then, so the fallback is one flat render timeout later. await vi.advanceTimersByTimeAsync(100 + 8_000 - 1) @@ -193,7 +201,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -230,7 +238,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') await vi.runAllTimersAsync() @@ -258,7 +266,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') - const result = await runtime.sendTerminal(handle, { text }) + const result = await runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(result).toMatchObject({ handle, @@ -285,7 +293,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three` - await runtime.sendTerminal(handle, { text, enter: true }) + await runtime.sendTerminal(handle, { text, enter: true }, { inputKind: 'driving' }) expect(writes.at(-1)).toBe('\r') expect(writes.slice(0, -1).join('')).toBe(text) @@ -312,7 +320,7 @@ describe('OrcaRuntimeService', () => { vi.useFakeTimers() try { - const sendPromise = runtime.sendTerminal(handle, { text }) + const sendPromise = runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(writes).toEqual([]) @@ -346,7 +354,11 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect( - runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }) + runtime.sendTerminal( + handle, + { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }, + { inputKind: 'driving' } + ) ).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR) expect(writes).toEqual([]) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index 65757797f70..e122effd17d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -230,7 +230,7 @@ describe('OrcaRuntimeService', () => { expect(new Set(handles).size).toBe(handles.length) await expect( - runtime.sendTerminal('term_victim', { text: 'for victim' }) + runtime.sendTerminal('term_victim', { text: 'for victim' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writesByPty.get('pty-victim')).toEqual(['for victim']) expect(writesByPty.has('pty-imposter')).toBe(false) @@ -260,7 +260,7 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listTerminals() expect(listed.terminals[0]?.handle).toBe('term_already_bound') await expect( - runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + runtime.sendTerminal('term_already_bound', { text: 'still routed' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual(['still routed']) // the reported-but-not-adopted handle must not resolve to the live pty @@ -347,7 +347,9 @@ describe('OrcaRuntimeService', () => { handle, tail: ['after unavailable'] }) - await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'still writable' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 1ebf15cbc24..9f2e747ba9a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -54,7 +54,7 @@ describe('OrcaRuntimeService', () => { tail: ['after restart'] }) await expect( - runtime.sendTerminal('term_exported', { text: 'still writable' }) + runtime.sendTerminal('term_exported', { text: 'still writable' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ handle: 'term_exported', accepted: true @@ -179,7 +179,7 @@ describe('OrcaRuntimeService', () => { ]) expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) - await runtime.sendTerminal('term_agent', { text: 'input' }) + await runtime.sendTerminal('term_agent', { text: 'input' }, { inputKind: 'driving' }) await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) expect(writes).toEqual([['pty-agent', 'input']]) expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 8980dc53f85..4f89b097cbb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -65,7 +65,7 @@ describe('OrcaRuntimeService', () => { const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`) restarted.onPtyData('persisted-pty', 'after restart\n', 1) - await restarted.sendTerminal('term_current', { text: 'input' }) + await restarted.sendTerminal('term_current', { text: 'input' }, { inputKind: 'driving' }) await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41) expect(beforeRestart.tabs[0]).toMatchObject({ @@ -183,7 +183,9 @@ describe('OrcaRuntimeService', () => { const shown = await runtime.showTerminal(entry!.handle) expect(shown.writable).toBe(true) - await expect(runtime.sendTerminal(entry!.handle, { text: 'hi' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(entry!.handle, { text: 'hi' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual([['pty-orphan', 'hi']]) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index cc73ffa2718..5a5c9521403 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -418,7 +418,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-startup-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('keeps the 8s main-runtime startup readiness budget for agents without an override', async () => { @@ -554,7 +558,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-opencode-draft-budget', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-opencode-draft-budget', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('rejects explicit startup commands for disabled selected agents', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index 2ec8e73f800..cc2c2cae2ba 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -83,7 +83,7 @@ describe('OrcaRuntimeService', () => { }) ) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r') + expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch') }) }) @@ -526,7 +526,11 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-explicit-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) }) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 5e90602755c..1597084eee1 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -27,7 +27,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ptyId: string, generation: number, pastePayload: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -62,7 +62,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // beginning when a large frame is split into independently processed chunks. renderGate?.arm() const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload - if (!this.ptyController?.write(ptyId, initialWrite)) { + if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { throw new Error('terminal_not_writable') } } catch (error) { @@ -103,7 +103,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) if (!submitWithPaste) { - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { + if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 2f4f5bf9b5a..3c98fa30200 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -11,7 +11,7 @@ export type CoordinatorRuntime = { sendTerminalAgentPrompt( handle: string, prompt: string, - options?: DispatchPreambleSendOptions + options: DispatchPreambleSendOptions ): Promise listTerminals( worktreeSelector?: string, diff --git a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts index 8dbedc9b33c..52016aa1465 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts @@ -24,7 +24,8 @@ export function writeOrchestrationPointerWithSettlement( return writeRefused('provider_cannot_settle') } try { - return settledWrite.call(args.controller, args.ptyId, args.data) + // Why driving: a pointer is input that tells a running agent to read its mail. + return settledWrite.call(args.controller, args.ptyId, args.data, 'driving') } catch { // A partial write that then threw cannot prove the transport took nothing. return writeUnverifiable('provider_threw_after_handoff', true) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 208868ce45b..a9fbb528939 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -147,12 +147,13 @@ ${params.taskSpec}` export type DispatchPreambleSendOptions = Pick< RuntimeAgentPromptWriteOptions, - 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' + 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' | 'inputKind' > export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions { // Why: a delayed provider hook must not revoke an accepted Dispatch. return { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE, acceptQueued: true, observationTimeoutMs: 0, diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index 41081daeb99..ac66912e747 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -66,6 +66,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { return false } const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, { + inputKind: 'launch', // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index dd4ce2522ea..d9798a47372 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -100,7 +100,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' method.handler(method.params!.parse(params) as never, { runtime } as never) ).resolves.toMatchObject({ send: { accepted: true } }) } - expect(write).toHaveBeenCalledWith('pty-worker', 'x') + expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving') expect(commit).toHaveBeenCalledTimes(1) expect(dbAccess).not.toHaveBeenCalled() expect(takeover).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index 593d097580f..526b09a6320 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -381,6 +381,7 @@ describe('orchestration worker-start prompt contract', () => { const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const pending = runtime.sendTerminalAgentPrompt(handle, 'queued prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'busy-swallowed', observationTimeoutMs: 0 diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index b28fed95098..59fd827f0ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -66,10 +66,11 @@ export async function sendTerminalStreamInput( const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) + const result = await runtime.sendTerminal(args.terminal, action, { inputKind: 'driving' }) return result.accepted ? 'delivered' : 'rejected' } const result = await runtime.sendTerminal(args.terminal, action, { + inputKind: 'driving', reserveWrite: (writePtyId) => { const claim = runtime.beginMobileInputFloor(writePtyId, clientId) if (!claim) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 99c01d902a4..ca7ac0aeb75 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -210,6 +210,7 @@ export const TERMINAL_SEND_METHODS = [ try { result = useSettledAgentPrompt ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + inputKind: 'driving', beforeWrite, signal, ...(orchestrationMutation @@ -234,6 +235,8 @@ export const TERMINAL_SEND_METHODS = [ { beforeWrite, signal, + // Why: a wire write carries no provenance beyond a client's own query reply. + inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving', ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 29254fbcf3a..824513dd999 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -45,6 +45,7 @@ describe('terminal agent prompt send RPC', () => { expect(response.ok).toBe(true) expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1') expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { + inputKind: 'driving', beforeWrite: undefined, signal: undefined }) @@ -81,7 +82,7 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'echo x', enter: true, interrupt: false }, - { beforeWrite: undefined, signal: undefined } + { inputKind: 'driving', beforeWrite: undefined, signal: undefined } ) expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts index 3dc1f6b2918..f93b772994e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts @@ -241,11 +241,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) handlers.get(16)?.( @@ -434,11 +438,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-8', { - text: 'remote-still-interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-8', + { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const frameCountBeforeAck = binaryFrames.length diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts index 03327859c62..57abea846ae 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts @@ -413,11 +413,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) binaryFrames.splice(0) diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 45ff2d77b8c..562181b2752 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -206,11 +206,15 @@ describe('terminal multiplex RPC', () => { ) ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'ls\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) @@ -248,11 +252,15 @@ describe('terminal multiplex RPC', () => { ) } await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { - text: 'retry', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenLastCalledWith( + 'terminal-1', + { + text: 'retry', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) dataListenerRef.current?.('a') diff --git a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts index b7fb20b2f73..ae2298a20dc 100644 --- a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts @@ -327,11 +327,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'echo one\necho two\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'echo one\necho two\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving') @@ -403,11 +407,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'printf a\nprintf b\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'printf a\nprintf b\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-1:desktop-1') diff --git a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts index ef6087923bb..eecd7b03859 100644 --- a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts @@ -309,7 +309,11 @@ describe('terminal multiplex RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'x', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index e597f5e5b13..214a5a2814e 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -347,7 +347,11 @@ describe('terminal output batching', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'ls\r', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae0..9aa55eb823e 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -232,6 +232,7 @@ describe('terminal send RPC', () => { interrupt: false }, { + inputKind: 'driving', beforeWrite: undefined, reserveWrite: expect.any(Function), afterWrite: expect.any(Function) @@ -370,7 +371,7 @@ describe('terminal send RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: '\x1b[3;4R', enter: false, interrupt: false }, - { beforeWrite: undefined } + { beforeWrite: undefined, inputKind: 'query-reply' } ) expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) @@ -570,7 +571,7 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { inputKind: 'driving', beforeWrite: undefined } ) }) @@ -653,7 +654,7 @@ describe('terminal send RPC', () => { enter: true, interrupt: false }, - { beforeWrite: expect.any(Function) } + { inputKind: 'driving', beforeWrite: expect.any(Function) } ) }) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 54aae2321ad..7b540a05e9a 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimePtyController = { claimStablePaneCreate?(args: { @@ -93,9 +94,13 @@ export type RuntimePtyController = { stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> - write(ptyId: string, data: string): boolean + write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean /** Three-valued settlement; local providers settle synchronously. */ - writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise + writeWithSettlement?( + ptyId: string, + data: string, + inputKind: TerminalInputKind + ): WriteSettlement | Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -106,7 +111,6 @@ export type RuntimePtyController = { ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise - markReversibleStops?(ptyIds: readonly string[]): () => void /** Durably records a kill order for an explicit close's unconfirmed stop, replayed when its SSH * host reconnects. True only when an order was written; local PTYs have no later host to ask. */ recordUnconfirmedStop?(ptyId: string): boolean diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0dcf355bfe0..fa0e7a0afa0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -16,6 +16,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' @@ -203,7 +204,9 @@ export type RuntimeProviderSnapshotReadOptions = { } /** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */ -export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & { +export type RuntimeAgentPromptWriteOptions = Omit & { + /** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */ + inputKind: Exclude /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string /** See buildAgentPromptPasteBytes. */ diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 7ff6ba4a7f3..10e94f3747f 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,8 +1,10 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { + inputKind: TerminalInputKind signal?: AbortSignal beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -12,7 +14,7 @@ export type RuntimeTerminalWriteOptions = { export class RuntimeTerminalWriter { constructor( - private readonly write: (ptyId: string, data: string) => boolean, + private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, private readonly getAgent: (ptyId: string) => TuiAgent | null = () => null @@ -22,7 +24,7 @@ export class RuntimeTerminalWriter { ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. @@ -54,7 +56,7 @@ export class RuntimeTerminalWriter { throw error } options.reserveWrite?.(ptyId) - if (!this.write(ptyId, suffix)) { + if (!this.write(ptyId, suffix, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -65,7 +67,7 @@ export class RuntimeTerminalWriter { } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, payload)) { + if (!this.write(ptyId, payload, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -74,14 +76,14 @@ export class RuntimeTerminalWriter { async writeChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, chunk.value)) { + if (!this.write(ptyId, chunk.value, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 7208e0d3bde..779f16ec6a4 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -4,6 +4,7 @@ import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-sca import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup @@ -19,7 +20,7 @@ export type WorktreeStartupReadinessHost = { hasChildProcesses?: (ptyId: string) => Promise subscribeToData: (ptyId: string, listener: (data: string) => void) => () => void readRecentOutput: (ptyId: string) => string | undefined - write: (ptyId: string, data: string) => void + write: (ptyId: string, data: string, inputKind: TerminalInputKind) => void } export function pasteWorktreeStartupDraftWhenReady( @@ -33,7 +34,7 @@ export function pasteWorktreeStartupDraftWhenReady( console.warn('[worktree-create] agent did not become ready for draft paste') return } - host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`) + host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error)) } @@ -49,7 +50,7 @@ export function sendWorktreeStartupFollowupWhenReady( console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } - host.write(ptyId, `${followup.prompt}\r`) + host.write(ptyId, `${followup.prompt}\r`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to send startup follow-up prompt:', error) diff --git a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts index 628139923b0..81287bafc59 100644 --- a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts +++ b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts @@ -37,8 +37,7 @@ export async function runObservedExitSocketScenario(scenario: ObservedExitSocket rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit, sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map() + retiredRejectedPtyIds: new Map() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused. const deps = ports as unknown as PtyRuntimeControllerDeps diff --git a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts new file mode 100644 index 00000000000..e806e88320c --- /dev/null +++ b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../shared/source-scan/source-tree-scan' +import { + findCallsMissingArgument, + type RequiredCallArgument +} from '../../shared/source-scan/call-argument-scan' + +/** + * Every PTY write names its input kind, and the compiler enforces that everywhere except the + * runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently + * record nothing, so this scan is the ratchet for them. + */ +const MAIN_ROOT = resolve(__dirname, '..') + +const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal) +const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({ + index, + ...(receiver ? { receiver } : {}), + acceptsObjectLiteral: namesInputKind +}) +const CONTROLLER = /[Cc]ontroller\??\s*$/ + +const KIND_ARGUMENT_BY_METHOD: Record = { + write: kindAt(2, CONTROLLER), + writeWithSettlement: kindAt(2, CONTROLLER), + sendTerminal: kindAt(2), + sendTerminalAgentPrompt: kindAt(2), + writeTerminalAction: kindAt(3), + writeTerminalInputChunks: kindAt(2), + writeTerminalAgentPrompt: kindAt(4), + writeAction: kindAt(3), + writeChunks: kindAt(2) +} + +// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`. +const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) => + /^\/\/ @ts-nocheck\b/m.test(file.source) +) + +describe('PTY write call sites the compiler cannot check', () => { + it('scans the unchecked runtime files that write to a PTY', () => { + expect(uncheckedSources.map((file) => file.relativePath)).toEqual( + expect.arrayContaining([ + 'runtime/orca-runtime-deliver-pending-messages.ts', + 'runtime/orca-runtime-create-pty-headless-terminal-state.ts', + 'runtime/orca-runtime-write-terminal-agent-prompt.ts', + 'runtime/orca-runtime-sync-window-graph.ts' + ]) + ) + }) + + it('finds a write, prompt or send that leaves out its kind', () => { + const planted = [ + '', + 'this.ptyController?.write(ptyId, reply)', + "this.ptyController.write(ptyId, '\\r', 'launch')", + "await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + 'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })', + 'other.write(ptyId, data)', + 'const controller = this.ptyController; controller.write(ptyId, data)' + ].join('\n') + + expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([ + '2: .write(ptyId, reply)', + "4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + '7: .write(ptyId, data)' + ]) + }) + + it('passes an input kind at every write', () => { + const missing = uncheckedSources.flatMap((file) => + findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map( + (site) => `${file.relativePath}:${site}` + ) + ) + + expect(missing).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts new file mode 100644 index 00000000000..0c3f996153f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { Store } from '../persistence/loading-store/store' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners' +import { + stopRendererOwnedPty, + stopReplacedPanePty, + type PtyKillIpcDeps +} from '../ipc/pty/ipc/renderer-kill' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import { createPtyIpcSession } from '../ipc/pty/session' +import type { IPtyProvider } from '../providers/types' +import { OrcaRuntimeService } from './orca-runtime' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' +const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' +const LATER_INCARNATION_ID = '88888888-8888-4888-8888-888888888888' + +const directories: string[] = [] +const stores: Store[] = [] +const priorProvider = getLocalPtyProvider() +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + for (const store of stores.splice(0)) { + store.freezeWrites() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** A real store and runtime with one bound pane, and main's exit delivery to a renderer stub. + * `lateProviderExit`: the kill's reply overtakes the exit, so main synthesizes one and the + * provider's own exit arrives later through its listener. */ +function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-')) + directories.push(directory) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(directory, 'profile-state.db'), + 'intentional-stop' + ) + }) + stores.push(store) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1, + // Why: a folder workspace resolves without git, which the sleep transaction needs. + ...(opts.folder ? { kind: 'folder' as const } : {}) + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID)) + store.flushOrThrow() + const runtime = new OrcaRuntimeService(store) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + ptyOwnership.set(PTY_ID, null) + ptyIncarnationById.set(PTY_ID, INCARNATION_ID) + let emitProviderExit: + | ((payload: { id: string; code: number; incarnationId?: string }) => void) + | undefined + const provider = { + onData: () => () => {}, + onExit: (listener: typeof emitProviderExit) => { + emitProviderExit = listener + return () => {} + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners bind only onData and onExit, and the renderer kill hands the provider to the shutdown port below. + setLocalPtyProvider(provider as unknown as IPtyProvider) + const rendererSend = vi.fn() + const window = { isDestroyed: () => false, webContents: { send: rendererSend } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exit delivery reads only isDestroyed and webContents.send. + const session = createPtyIpcSession({ mainWindow: window as unknown as BrowserWindow, runtime }) + wirePtyIpcSession(session) + bindProviderListeners(session) + const deps: PtyKillIpcDeps = { + store, + runtime, + getLocalPtyProviderStartupPromise: () => undefined, + // The provider's own exit, delivered the way its listener delivers it. + shutdownProviderAndDetectExit: async (_provider, id) => { + if (opts.lateProviderExit) { + return false + } + runtime.onPtyExit(id, 0, INCARNATION_ID, { providerExitObserved: true }) + session.sendPtyExitToRenderer({ id, code: 0, incarnationId: INCARNATION_ID }) + return true + }, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + } + return { + store, + runtime, + deps, + emitProviderExit: (incarnationId: string) => + emitProviderExit?.({ id: PTY_ID, code: 0, incarnationId }), + boundPtyId: () => + store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] ?? null, + tabIds: () => (store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID] ?? []).map((t) => t.id), + rendererExits: () => + rendererSend.mock.calls.filter(([channel]) => channel === 'pty:exit').map(([, p]) => p) + } +} + +describe('intentional stops keep the pane through the exit', () => { + it('retires the pane when an ordinary close ends the process', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID }) + + expect(harness.boundPtyId()).toBeNull() + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID } + ]) + }) + + it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('keeps a typed pane that a restart replaces, and binds the replacement', async () => { + const harness = createHarness() + harness.runtime.terminalRunFacts.recordSpawnCommit({ + id: PTY_ID, + incarnationId: INCARNATION_ID + }) + harness.runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'ls\r') + + await stopReplacedPanePty(harness.deps, PTY_ID) + expect(harness.boundPtyId()).toBe(PTY_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: REPLACEMENT_PTY_ID, + incarnationId: REPLACEMENT_INCARNATION_ID, + origin: 'spawn' + }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(REPLACEMENT_PTY_ID) + expect( + harness.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[ + makePaneKey(TAB_ID, LEAF_ID) + ] + ).toBe(REPLACEMENT_INCARNATION_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, replacedByRestart: true } + ]) + }) + + it('labels the exit for both a sleep and a restart that stop the same process', async () => { + const harness = createHarness() + const settleSleep = harness.runtime.intentionalPtyStops.mark( + PTY_ID, + 'reversible', + INCARNATION_ID + ) + + await stopReplacedPanePty(harness.deps, PTY_ID) + settleSleep(true) + + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { + id: PTY_ID, + code: 0, + incarnationId: INCARNATION_ID, + preserveRendererBinding: true, + replacedByRestart: true + } + ]) + }) + + it('keeps the pane through the synthetic exit and the provider exit that follows it', async () => { + const harness = createHarness({ lateProviderExit: true }) + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.emitProviderExit(INCARNATION_ID) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: -1, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('never reads the exit of a later process on the same id as the stop', async () => { + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: LATER_INCARNATION_ID + }) + ptyIncarnationById.set(PTY_ID, LATER_INCARNATION_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + incarnationId: LATER_INCARNATION_ID, + origin: 'reattach' + }) + + harness.emitProviderExit(LATER_INCARNATION_ID) + + // Why wait: an unstopped exit retires the pane through an async durable save. + await vi.waitFor(() => expect(harness.boundPtyId()).toBeNull()) + expect(harness.rendererExits().at(-1)).toEqual({ + id: PTY_ID, + code: 0, + incarnationId: LATER_INCARNATION_ID + }) + }) + + it('forgets the stop once the duplicate-exit window after it closes', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([ + 'reversible' + ]) + vi.advanceTimersByTime(1) + + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => { + const harness = createHarness({ folder: true }) + const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]] + harness.runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async (ptyId) => { + harness.runtime.onPtyExit(ptyId, -1, INCARNATION_ID, { providerExitObserved: true }) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => inventories.shift() ?? [] + }) + + await harness.runtime.sleepTerminalsForWorktree(`id:${WORKTREE_ID}`) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.test.ts b/src/main/runtime/terminal-intentional-stops.test.ts new file mode 100644 index 00000000000..d37fc4147e8 --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { TerminalIntentionalStops } from './terminal-intentional-stops' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminal intentional stops', () => { + it('keeps the mark while a second overlapping owner still holds it', () => { + const stops = new TerminalIntentionalStops() + const settleFirst = stops.mark('pty-1', 'reversible', 'inc-1') + const settleSecond = stops.mark('pty-1', 'reversible', 'inc-1') + + settleFirst(false) + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + settleSecond(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('still reads an exit that lands after the stop settled, until the window closes', () => { + vi.useFakeTimers() + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-ssh', 'reversible', 'inc-1') + + settle(true) + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + + expect(stops.isReversibleStopInFlight('pty-ssh')).toBe(false) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual(['reversible']) + vi.advanceTimersByTime(1) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual([]) + }) + + it('drops the mark at once when the stop fails', () => { + const stops = new TerminalIntentionalStops() + + stops.mark('pty-1', 'replaced', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('reads the synthetic exit and the provider exit of the same process alike', () => { + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-1', 'replaced', null) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + settle(true) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('never marks the exit of another process that reuses the id', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1') + + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('starts a new stop of the same id fresh once the prior one settled', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + const settle = stops.mark('pty-1', 'replaced', 'inc-2') + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual(['replaced']) + settle(false) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('labels one exit with every kind of stop that overlapped on it', () => { + const stops = new TerminalIntentionalStops() + const settleSleep = stops.mark('pty-1', 'reversible', 'inc-1') + const settleRestart = stops.mark('pty-1', 'replaced', 'inc-1') + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible', 'replaced']) + + settleSleep(true) + expect(stops.isReversibleStopInFlight('pty-1')).toBe(false) + settleRestart(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('keeps a landed stop when a later stop of the same process fails', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + stops.mark('pty-1', 'reversible', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('lets a new process on the id supersede a landed stop no exit pinned', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-unpinned', 'reversible', null)(true) + stops.mark('pty-pinned', 'reversible', 'inc-1')(true) + stops.mark('pty-in-flight', 'replaced', null) + + for (const ptyId of ['pty-unpinned', 'pty-pinned', 'pty-in-flight']) { + stops.noteSpawnCommit(ptyId) + } + + expect(stops.claimExit('pty-unpinned', null)).toEqual([]) + expect(stops.claimExit('pty-pinned', 'inc-1')).toEqual(['reversible']) + expect(stops.claimExit('pty-in-flight', null)).toEqual(['replaced']) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts new file mode 100644 index 00000000000..b89bc709e5f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -0,0 +1,122 @@ +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' + +/** + * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: + * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `replaced`: a restart stops the old process so a new one can take the pane. + */ +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' + +type IntentionalStopOwners = { inFlight: number; stopped: boolean } + +type IntentionalStop = { + /** Null until known; the first exit that claims the stop pins it to that process. */ + incarnationId: string | null + /** Why per kind: overlapping stops of different kinds each hold their own label on the exit. */ + ownersByKind: Map + expiryTimer?: ReturnType +} + +const NO_INTENTIONAL_STOP: readonly TerminalIntentionalStopKind[] = [] + +function hasInFlightOwners(stop: IntentionalStop): boolean { + return [...stop.ownersByKind.values()].some((owners) => owners.inFlight > 0) +} + +/** The one register of PTY stops main made on purpose, read by every exit path. */ +export class TerminalIntentionalStops { + private readonly stopsByPtyId = new Map() + + /** Registers one owner's stop; call the result once with whether the stop landed. */ + mark( + ptyId: string, + kind: TerminalIntentionalStopKind, + incarnationId: string | null + ): (stopped: boolean) => void { + let stop = this.stopsByPtyId.get(ptyId) + if (stop && this.joins(stop, incarnationId)) { + clearTimeout(stop.expiryTimer) + stop.expiryTimer = undefined + stop.incarnationId ??= incarnationId + } else { + clearTimeout(stop?.expiryTimer) + stop = { incarnationId, ownersByKind: new Map() } + this.stopsByPtyId.set(ptyId, stop) + } + const owners = stop.ownersByKind.get(kind) ?? { inFlight: 0, stopped: false } + owners.inFlight += 1 + stop.ownersByKind.set(kind, owners) + const owned = stop + let settled = false + return (stopped) => { + if (settled || this.stopsByPtyId.get(ptyId) !== owned) { + return + } + settled = true + owners.stopped ||= stopped + owners.inFlight -= 1 + if (owners.inFlight === 0 && !owners.stopped) { + owned.ownersByKind.delete(kind) + } + this.settleIfIdle(ptyId, owned) + } + } + + /** The kinds of stop this exit ends; empty when the process was not stopped on purpose. */ + claimExit( + ptyId: string, + exitIncarnationId: string | null | undefined + ): readonly TerminalIntentionalStopKind[] { + const stop = this.stopsByPtyId.get(ptyId) + if (!stop) { + return NO_INTENTIONAL_STOP + } + if (stop.incarnationId && exitIncarnationId && stop.incarnationId !== exitIncarnationId) { + return NO_INTENTIONAL_STOP + } + stop.incarnationId ??= exitIncarnationId ?? null + return [...stop.ownersByKind.keys()] + } + + /** Whether a stop of this PTY that may still be undone is in flight. */ + isReversibleStopInFlight(ptyId: string): boolean { + return (this.stopsByPtyId.get(ptyId)?.ownersByKind.get('reversible')?.inFlight ?? 0) > 0 + } + + /** A process committed on this id. A landed stop's process is dead, so an entry no exit ever + * pinned could otherwise claim the new process's exit as the stop. */ + noteSpawnCommit(ptyId: string): void { + const stop = this.stopsByPtyId.get(ptyId) + if (stop && stop.incarnationId === null && !hasInFlightOwners(stop)) { + clearTimeout(stop.expiryTimer) + this.stopsByPtyId.delete(ptyId) + } + } + + // Why: a settled entry joins only its own known process, so an id reused by a process whose + // incarnation is not yet known never inherits the old stop. + private joins(stop: IntentionalStop, incarnationId: string | null): boolean { + if (stop.incarnationId !== null && stop.incarnationId === incarnationId) { + return true + } + return hasInFlightOwners(stop) && (stop.incarnationId === null || incarnationId === null) + } + + private settleIfIdle(ptyId: string, stop: IntentionalStop): void { + if (hasInFlightOwners(stop)) { + return + } + if (stop.ownersByKind.size === 0) { + this.stopsByPtyId.delete(ptyId) + return + } + // Why a window: an SSH exit can arrive after the stop settles, and a synthetic exit can be + // followed by the provider's own; both describe the same stopped process. + stop.expiryTimer = setTimeout(() => { + if (this.stopsByPtyId.get(ptyId) === stop) { + this.stopsByPtyId.delete(ptyId) + } + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + stop.expiryTimer.unref?.() + } +} diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 3a6470b41f9..b79ea7a3270 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -92,9 +92,9 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { await expect( assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) ).rejects.toThrow('terminal_guard_permission') - await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) it('lets a dispatch preamble through once the same lane is working', async () => { diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 9db883529a0..f479be9e47a 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -21,6 +21,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -58,9 +59,11 @@ type RendererBufferStub = { data: string; cols: number; rows: number } function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { const runtime = new OrcaRuntimeService(store) const replies: { ptyId: string; data: string }[] = [] + const inputKinds: TerminalInputKind[] = [] runtime.setPtyController({ - write: (ptyId, data) => { + write: (ptyId, data, inputKind) => { replies.push({ ptyId, data }) + inputKinds.push(inputKind) return true }, kill: () => true, @@ -74,7 +77,7 @@ function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = } : {}) }) - return { runtime, replies } + return { runtime, replies, inputKinds } } /** Awaits the per-PTY emulator writeChain so queued chunk links (and the @@ -146,7 +149,7 @@ describe('reply parity for hidden-dropped chunks', () => { ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] ])('%s', async (_label, chunk, expectedReplies) => { - const { runtime, replies } = createResponderRuntime() + const { runtime, replies, inputKinds } = createResponderRuntime() markHiddenRendererPty('pty-q') runtime.onPtyData('pty-q', chunk, Date.now()) @@ -154,6 +157,8 @@ describe('reply parity for hidden-dropped chunks', () => { expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + // Why: a reply written as driving input would read the untouched run as typed. + expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply')) }) it.each([ diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts new file mode 100644 index 00000000000..a1f26d9b686 --- /dev/null +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { writePtyFromRuntimeController } from '../ipc/pty/runtime/operations' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import type { IPtyProvider } from '../providers/types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' +import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-delivery' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { + pasteWorktreeStartupDraftWhenReady, + sendWorktreeStartupFollowupWhenReady, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/run-facts', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const PTY_ID = 'pty-run-facts-input' +const priorProvider = getLocalPtyProvider() + +type FreshRun = { + runtime: OrcaRuntimeService + controller: RuntimePtyController + handle: string + /** The kind each provider write was sent with, and whether input was recorded by then. */ + writes: { data: string; inputRecorded: boolean }[] + kinds: TerminalInputKind[] + firstUserInputAt: () => number | null +} + +/** A fresh shell whose controller writes go through main's real write funnel to a fake provider. */ +async function createFreshRun(): Promise { + const runtime = new OrcaRuntimeService(makeStore() as never) + const firstUserInputAt = (): number | null => + runtime.terminalRunFacts.read(PTY_ID, null).firstUserInputAt + const writes: FreshRun['writes'] = [] + const kinds: TerminalInputKind[] = [] + const write = (_id: string, data: string): boolean => { + writes.push({ data, inputRecorded: firstUserInputAt() !== null }) + if (data.endsWith('\r')) { + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', Date.now()) + } + return true + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write funnel calls only write and writeWithSettlement on the provider. + setLocalPtyProvider({ + write, + writeWithSettlement: settledWriteStub(write) + } as unknown as IPtyProvider) + const controller: RuntimePtyController = { + spawn: async () => ({ id: PTY_ID }), + write: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind) + }, + writeWithSettlement: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind, { + waitForSettlement: true + }) + }, + kill: () => true, + getForegroundProcess: async () => null + } + runtime.setPtyController(controller) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a', { launchAgent: 'aider' }) + runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID }) + return { runtime, controller, handle: terminal.handle, writes, kinds, firstUserInputAt } +} + +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) +}) + +describe('run facts: the controller write funnel', () => { + it('records terminal.send input before the write that could end the process', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal( + run.handle, + { text: 'exit', enter: true }, + { inputKind: 'driving' } + ) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + }) + + it('records stream input from a client', async () => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text: 'l', + client: undefined, + isMobile: false + }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a dispatched agent prompt before its first write', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes[0]?.inputRecorded).toBe(true) + }) + + it('reads a run launched with a prompt as untyped until someone drives it', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'start here', { + inputKind: 'launch' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.writes.length).toBeGreaterThan(0) + expect(run.firstUserInputAt()).toBeNull() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'driving' }) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a mailbox pointer, which drives the running agent', async () => { + const run = await createFreshRun() + + await writeOrchestrationPointerWithSettlement({ + ptyId: PTY_ID, + data: 'You have 1 unread message.', + controller: run.controller + }) + + expect(run.kinds).toEqual(['driving']) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records nothing for a client query reply', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' }) + + expect(run.firstUserInputAt()).toBeNull() + }) + + it.each([ + ['a terminal reply', '\x1b[3;4R'], + ['focus reports', '\x1b[I\x1b[O'], + ['the focus-in a desktop sends on reattaching a remote pane', '\x1b[I'] + ])('records nothing for stream input that is only %s', async (_label, text) => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text, + client: undefined, + isMobile: false + }) + + expect(run.writes).toHaveLength(1) + expect(run.firstUserInputAt()).toBeNull() + }) + + it('records a reply mixed with a keystroke', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records dashboard preview typing before the write that could end the process', async () => { + const run = await createFreshRun() + + await expect(run.runtime.writeTerminalPreviewInput(PTY_ID, 'exit\r')).resolves.toBe(true) + + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true]) + }) + + it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => { + const run = await createFreshRun() + + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R') + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I') + + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) + +describe('run facts: a created worktree’s startup writes', () => { + function readinessHost(run: FreshRun): WorktreeStartupReadinessHost { + return { + getPtyId: () => PTY_ID, + getForegroundProcess: async () => 'aider', + subscribeToData: () => () => {}, + // Why: bracketed paste enabled, then quiet, is the default draft-ready signal. + readRecentOutput: () => '\x1b[?2004h', + write: (ptyId, data, inputKind) => run.controller.write(ptyId, data, inputKind) + } + } + + it('reads a run whose only input was its create-time draft and follow-up as untyped', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + const host = readinessHost(run) + + pasteWorktreeStartupDraftWhenReady(host, run.handle, { agent: 'aider', content: 'plan it' }) + sendWorktreeStartupFollowupWhenReady(host, run.handle, { + expectedProcess: 'aider', + prompt: 'and ship it' + }) + await vi.runAllTimersAsync() + + expect(run.kinds).toEqual(['launch', 'launch']) + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts new file mode 100644 index 00000000000..e1982ebc968 --- /dev/null +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { TerminalRunFactsRegister } from './terminal-run-facts' + +describe('terminal run facts', () => { + it('reads a run main never saw committed as not fresh', () => { + expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ + freshSpawn: false, + firstUserInputAt: null + }) + }) + + it('keeps the first user input across later input and a re-registration of the same process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'driving', 'ls\r', 200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: true, firstUserInputAt: 100 }) + }) + + it('starts a new process clean', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }, { tabId: 'source-tab' }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it('never reads a reattached process as fresh', () => { + const facts = new TerminalRunFactsRegister() + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) + }) + + it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[1;1R'], + ['driving bytes that are only a terminal reply', 'driving', '\x1b[1;1R'], + ['driving bytes that are only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + + facts.recordInput('pty-1', inputKind, data, 100) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts new file mode 100644 index 00000000000..1fb3e411b6c --- /dev/null +++ b/src/main/runtime/terminal-run-facts.ts @@ -0,0 +1,90 @@ +import { + spawnCommitBindingOrigin, + type PtySpawnCommitOrigin +} from '../persistence/loading-store/pty-binding-span' +import { isTerminalQueryReply } from '../../shared/terminal-query-reply' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' + +export type TerminalRunFacts = { + /** This process was started for its pane, not reattached, adopted or cold-restored. */ + freshSpawn: boolean + /** When input first drove this process, from any client or driver; null if none has. */ + firstUserInputAt: number | null +} + +// Why: a paired client's xterm answers focus changes (CSI I / CSI O) through input that carries no +// provenance; the desktop renderer already excludes them via xterm's user-input signal. +// oxlint-disable-next-line no-control-regex -- focus reports are ESC-framed sequences by definition. +const TERMINAL_FOCUS_REPORTS_ONLY_RE = new RegExp('^(?:\\u001b\\[[IO])+$') + +/** Input with no provenance that no person typed: a whole terminal reply or only focus reports. */ +function isUntypedTerminalInput(payload: string): boolean { + return isTerminalQueryReply(payload) || TERMINAL_FOCUS_REPORTS_ONLY_RE.test(payload) +} + +export type TerminalSpawnCommit = Parameters[0] & { + id: string + incarnationId?: string + coldRestore?: object +} + +/** A cold restore starts a new process for a pane that had one, so it is never fresh. */ +type TerminalRunSpawnOrigin = PtySpawnCommitOrigin | 'cold-restore' + +type TerminalRunRecord = { + incarnationId: string | null + spawnOrigin: TerminalRunSpawnOrigin + firstUserInputAt: number | null +} + +/** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ +export class TerminalRunFactsRegister { + private readonly runsByPtyId = new Map() + + /** Once per process: a re-registration of the same incarnation keeps its facts. Without an + * incarnation a commit cannot be told from a new process, so it starts clean. */ + recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + const incarnationId = commit.incarnationId ?? null + if ( + incarnationId !== null && + this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId + ) { + return + } + const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + this.runsByPtyId.set(commit.id, { + incarnationId, + spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, + firstUserInputAt: null + }) + } + + /** The one record point both write funnels call just before the provider write, because input + * such as `exit` can end the process before the write returns. The payload check backs up a + * writer that labels a reply or focus report as driving. */ + recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { + if (inputKind !== 'driving') { + return + } + const run = this.runsByPtyId.get(ptyId) + if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { + run.firstUserInputAt = now + } + } + + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ + read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { + const run = this.runsByPtyId.get(ptyId) + if (!run || (run.incarnationId && incarnationId && run.incarnationId !== incarnationId)) { + return { freshSpawn: false, firstUserInputAt: null } + } + return { + freshSpawn: run.spawnOrigin === 'spawn' || run.spawnOrigin === 'split', + firstUserInputAt: run.firstUserInputAt + } + } + + delete(ptyId: string): void { + this.runsByPtyId.delete(ptyId) + } +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index b7c5ed95068..a25ed9817fb 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -96,9 +96,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledWith(STALE_PTY_ID) expect(write).not.toHaveBeenCalled() @@ -108,9 +108,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(write).not.toHaveBeenCalled() }) @@ -120,12 +120,14 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => null }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('treats a throwing probe as unknown and proceeds', async () => { @@ -135,11 +137,13 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { } }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds when the probe answers live (restored session before its pane remounts)', async () => { @@ -147,21 +151,25 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => true }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds unchanged when the controller exposes no probe', async () => { const { runtime, handle, write } = await makeRuntimeWithLeafHandle({}) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('never probes when the provider synchronously knows the id (live pty)', async () => { @@ -171,24 +179,26 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => ptyId === STALE_PTY_ID }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).not.toHaveBeenCalled() - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => { const probe = vi.fn(async () => false) const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) - await expect(runtime.sendTerminal(handle, { text: 'b' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledTimes(1) }) @@ -201,17 +211,19 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => livePtyIds.has(ptyId) }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') // Same id recreated by a fresh spawn: provider knowledge must beat the verdict. livePtyIds.add(STALE_PTY_ID) - await expect(runtime.sendTerminal(handle, { text: 'b' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b', 'driving') }) }) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index ca273aee07d..55937134bb1 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,6 +3,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -75,8 +76,8 @@ export type PtyApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e9b19dbe43a..225aec9618a 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -71,11 +72,11 @@ export const ptySessionControlApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, inputKind: TerminalInputKind): void => { + ipcRenderer.send('pty:write', { id, data, inputKind }) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index eedf8506e1c..334e4639add 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -147,7 +147,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: { // exact Claude warning defaults to “Yes” and needs only one Enter. const accepted = args.submitConfirmation ? args.submitConfirmation() !== false - : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving') if (!accepted) { finish('unknown') return diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index f77ba77e274..aba65acf8db 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -56,20 +56,20 @@ export function sendNativeChatMessageWithImageAttachments( ), trimmedText.length > 0 )) { - sendRuntimePtyInput(settings, ptyId, payload) + sendRuntimePtyInput(settings, ptyId, payload, 'driving') } if (trimmedText.length > 0) { delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) return } delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index d8cf90167ec..0071b73b21d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -138,7 +138,12 @@ describe('sendNativeChatMessage', () => { ]) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) @@ -194,13 +199,19 @@ describe('sendNativeChatMessageVerified', () => { expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('/model sonnet') + buildNativeChatPasteBytes('/model sonnet'), + 'driving' ) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(await result).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect( sendRuntimePtyInputVerified.mock.calls.some( (call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT @@ -234,7 +245,12 @@ describe('sendNativeChatMessageVerified', () => { const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT) // Only the verified path's Enter — chat's delayed Enter was cancelled. expect(submits).toHaveLength(0) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('returns false when the delayed Enter wait is aborted', async () => { @@ -347,9 +363,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { '\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ ' ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('clears the line, then bracket-pastes image paths before prompt text', () => { @@ -372,10 +393,20 @@ describe('sendNativeChatMessageWithImageAttachments', () => { ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + 'what do you see?', + 'driving' + ) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) @@ -396,7 +427,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => { vi.advanceTimersByTime(1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('treats whitespace-only prompt input as attachment-only', () => { @@ -450,7 +486,7 @@ describe('empty prompt submit', () => { it('submits an empty prompt with a bare Enter', () => { submitNativeChatPrompt(SETTINGS, PTY) expect(sendRuntimePtyInput).toHaveBeenCalledOnce() - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving') }) }) @@ -483,16 +519,17 @@ describe('sendNativeChatAskAnswer', () => { ) vi.advanceTimersByTime(0) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('custom answer') + buildNativeChatPasteBytes('custom answer'), + 'driving' ) }) @@ -533,7 +570,7 @@ describe('sendNativeChatAskAnswer', () => { const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled) await vi.advanceTimersByTimeAsync(handle.settleAfterMs) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') expect(onSettled).not.toHaveBeenCalled() resolveAccepted(true) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a845fe10d16..1eb7a676103 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -72,7 +72,12 @@ export function clearUnsubmittedAgentInput( ptyId: string, options?: NativeChatSendOptions ): void { - sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + sendRuntimePtyInput( + settings, + ptyId, + options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + 'driving' + ) } /** @@ -104,7 +109,7 @@ export function clearThenWrite( // An unreadable terminal is unconfirmed; the maximal clear remains safe. } if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') } writeBody() }) @@ -140,11 +145,11 @@ export function sendNativeChatMessage( if (isCancelled()) { return } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') // Schedule from the actual body write: an overdue clear-confirm callback // must not collapse the required body-to-Enter gap after a renderer stall. delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) @@ -203,12 +208,13 @@ export async function sendNativeChatMessageVerified( const bodyAccepted = await sendRuntimePtyInputVerified( settings, ptyId, - buildNativeChatPasteBytes(text) + buildNativeChatPasteBytes(text), + 'driving' ) if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { return false } - return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** Types a slash command as individual keys so Codex opens its command palette. */ @@ -224,7 +230,7 @@ export async function typeNativeChatCommand( command, signal, write: async (key) => - (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected' }) return outcome === 'accepted' } @@ -253,7 +259,9 @@ export function sendNativeChatTypedCommand( if (isCancelled()) { return 'rejected' } - return (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) + ? 'accepted' + : 'rejected' } }).then(finish, () => finish('rejected')) }, @@ -269,7 +277,7 @@ export function sendNativeChatTypedCommand( /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** @@ -297,10 +305,10 @@ export function sendNativeChatAskAnswer( // Why: inference must use the remote host's acceptance result, not // the fire-and-forget renderer dispatch result. verifiedWrites.push( - sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false) + sendRuntimePtyInputVerified(settings, ptyId, bytes, 'driving').catch(() => false) ) } else { - sendRuntimePtyInput(settings, ptyId, bytes) + sendRuntimePtyInput(settings, ptyId, bytes, 'driving') } }, index * NATIVE_CHAT_QUESTION_STEP_MS) ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts index 71a654c0927..f59d86303f1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts @@ -25,7 +25,7 @@ export function useNativeChatComposerInterrupt(args: { } const target = resolveTarget() if (target) { - sendRuntimePtyInput(target.settings, target.ptyId, ESC) + sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving') } }, [cancelPendingSends, isWorking, onStop, resolveTarget]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 864c62f6f91..f2cbd8df93c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -195,7 +195,8 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( { terminalTabId: 'tab-1' }, 'pty-1', - '\x1b' + '\x1b', + 'driving' ) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 43adabae8f3..3d270cde49b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -76,7 +76,12 @@ export function useNativeChatInteractiveSend( if (!targetPtyId) { return } - sendRuntimePtyInput(getSettingsForAgentTabRuntimeOwner(terminalTabId), targetPtyId, raw) + sendRuntimePtyInput( + getSettingsForAgentTabRuntimeOwner(terminalTabId), + targetPtyId, + raw, + 'driving' + ) }, [terminalTabId, targetPtyId] ) diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts index cba19b43c2f..c77339bfe2e 100644 --- a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts @@ -30,39 +30,52 @@ describe('deferred split pane handoff', () => { const cwdPromise = Promise.resolve('/source/cwd') const initial = beginDeferredSplitPaneHandoff(key, cwdPromise) - appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary' }) - appendDeferredSplitPaneInput(initial, { data: '\x1b[0n', kind: 'immediate' }) - appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted' }) + appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary', inputKind: 'driving' }) + appendDeferredSplitPaneInput(initial, { + data: '\x1b[0n', + kind: 'immediate', + inputKind: 'query-reply' + }) + appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted', inputKind: 'driving' }) const remounted = claimDeferredSplitPaneHandoff(key) expect(remounted?.cwdPromise).toBe(cwdPromise) expect(remounted?.preconnectInput).toEqual([ - { data: 'typed', kind: 'ordinary' }, - { data: '\x1b[0n', kind: 'immediate' }, - { data: '\x03', kind: 'accepted' } + { data: 'typed', kind: 'ordinary', inputKind: 'driving' }, + { data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' }, + { data: '\x03', kind: 'accepted', inputKind: 'driving' } ]) }) it('keeps input across repeated remounts and fences stale owners', () => { const key = makePaneKey('tab-1', LEAF_1) const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(initial, { data: 'before-first-remount', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'before-first-remount', + kind: 'ordinary', + inputKind: 'driving' + }) const firstRemount = claimDeferredSplitPaneHandoff(key) expect(firstRemount).not.toBeNull() - appendDeferredSplitPaneInput(initial, { data: 'stale-input', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'stale-input', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(initial) clearDeferredSplitPaneHandoff(initial) appendDeferredSplitPaneInput(firstRemount!.handle, { data: 'before-second-remount', - kind: 'ordinary' + kind: 'ordinary', + inputKind: 'driving' }) const secondRemount = claimDeferredSplitPaneHandoff(key) expect(secondRemount?.preconnectInput).toEqual([ - { data: 'before-first-remount', kind: 'ordinary' }, - { data: 'before-second-remount', kind: 'ordinary' } + { data: 'before-first-remount', kind: 'ordinary', inputKind: 'driving' }, + { data: 'before-second-remount', kind: 'ordinary', inputKind: 'driving' } ]) clearDeferredSplitPaneHandoff(firstRemount!.handle) @@ -74,21 +87,33 @@ describe('deferred split pane handoff', () => { it('releases an unmounted owner without dropping its pending handoff', () => { const key = makePaneKey('tab-1', LEAF_1) const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(initial, { data: 'before-unmount', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'before-unmount', + kind: 'ordinary', + inputKind: 'driving' + }) releaseDeferredSplitPaneHandoff(initial) - appendDeferredSplitPaneInput(initial, { data: 'late-stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'late-stale', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(initial) expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ - { data: 'before-unmount', kind: 'ordinary' } + { data: 'before-unmount', kind: 'ordinary', inputKind: 'driving' } ]) }) it('lets a late close discard a released handoff by its stable pane key', () => { const key = makePaneKey('tab-1', LEAF_1) const owner = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(owner, { data: 'must-not-replay', kind: 'ordinary' }) + appendDeferredSplitPaneInput(owner, { + data: 'must-not-replay', + kind: 'ordinary', + inputKind: 'driving' + }) // Whole-tab cleanup releases the mount-local handle before a stale close callback can run. releaseDeferredSplitPaneHandoff(owner) @@ -112,7 +137,11 @@ describe('deferred split pane handoff', () => { it('drops a stale record when an authoritative restored PTY wins the key', () => { const key = makePaneKey('tab-authoritative', LEAF_1) const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) - appendDeferredSplitPaneInput(stale, { data: 'must-not-replay', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { + data: 'must-not-replay', + kind: 'ordinary', + inputKind: 'driving' + }) discardDeferredSplitPaneHandoffForKey(key) @@ -123,26 +152,44 @@ describe('deferred split pane handoff', () => { it('replaces an older handoff for the same stable pane key', () => { const key = makePaneKey('tab-1', LEAF_1) const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) - appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary', inputKind: 'driving' }) const currentPromise = Promise.resolve('/current') const current = beginDeferredSplitPaneHandoff(key, currentPromise) - appendDeferredSplitPaneInput(stale, { data: 'late-stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { + data: 'late-stale', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(stale) - appendDeferredSplitPaneInput(current, { data: 'current', kind: 'ordinary' }) + appendDeferredSplitPaneInput(current, { + data: 'current', + kind: 'ordinary', + inputKind: 'driving' + }) const claimed = claimDeferredSplitPaneHandoff(key) expect(claimed?.cwdPromise).toBe(currentPromise) - expect(claimed?.preconnectInput).toEqual([{ data: 'current', kind: 'ordinary' }]) + expect(claimed?.preconnectInput).toEqual([ + { data: 'current', kind: 'ordinary', inputKind: 'driving' } + ]) }) it('retains input within the shared preconnect entry and code-unit caps', () => { const entryKey = makePaneKey('tab-entries', LEAF_1) const entryHandle = beginDeferredSplitPaneHandoff(entryKey, Promise.resolve('/entries')) for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - appendDeferredSplitPaneInput(entryHandle, { data: '', kind: 'ordinary' }) + appendDeferredSplitPaneInput(entryHandle, { + data: '', + kind: 'ordinary', + inputKind: 'driving' + }) } - appendDeferredSplitPaneInput(entryHandle, { data: 'overflow', kind: 'ordinary' }) + appendDeferredSplitPaneInput(entryHandle, { + data: 'overflow', + kind: 'ordinary', + inputKind: 'driving' + }) expect(claimDeferredSplitPaneHandoff(entryKey)?.preconnectInput).toHaveLength( PTY_PRECONNECT_INPUT_MAX_ENTRIES ) @@ -154,11 +201,20 @@ describe('deferred split pane handoff', () => { ) appendDeferredSplitPaneInput(codeUnitHandle, { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), - kind: 'ordinary' + kind: 'ordinary', + inputKind: 'driving' + }) + appendDeferredSplitPaneInput(codeUnitHandle, { + data: 'overflow', + kind: 'ordinary', + inputKind: 'driving' }) - appendDeferredSplitPaneInput(codeUnitHandle, { data: 'overflow', kind: 'ordinary' }) expect(claimDeferredSplitPaneHandoff(codeUnitKey)?.preconnectInput).toEqual([ - { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' } + { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary', + inputKind: 'driving' + } ]) }) @@ -209,12 +265,12 @@ describe('deferred split pane handoff', () => { it('does not expose the registry input array by reference', () => { const key = makePaneKey('tab-1', LEAF_1) const handle = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary' }) + appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary', inputKind: 'driving' }) const firstClaim = claimDeferredSplitPaneHandoff(key) firstClaim?.preconnectInput.splice(0) expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ - { data: 'kept', kind: 'ordinary' } + { data: 'kept', kind: 'ordinary', inputKind: 'driving' } ]) }) }) diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts index 6dfa1f30866..046b43daa9c 100644 --- a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts @@ -135,7 +135,7 @@ export function appendDeferredSplitPaneInput( ) { return } - owned.record.preconnectInput.push({ data: input.data, kind: input.kind }) + owned.record.preconnectInput.push({ ...input }) owned.record.inputCodeUnits += input.data.length } diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx index 7e6f9a02549..8a84eddbcef 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx @@ -328,7 +328,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { vi.runAllTimers() expect(harness.sendInput).toHaveBeenCalledTimes(1) - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) @@ -351,7 +351,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { vi.runAllTimers() expect(harness.sendInput).toHaveBeenCalledTimes(1) - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) @@ -374,7 +374,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false }) expect(harness.sendInput).toHaveBeenCalledOnce() - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts index 743f06f6f42..f2dfe0bd637 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts @@ -240,7 +240,7 @@ describe('connectPanePty', () => { const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void forward('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { @@ -253,7 +253,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() sendTerminalInputThroughPane(pane, 'a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps large ANSI redraws after captured shortcut input on the immediate path', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 17511577376..02c99e2aa4b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -490,7 +490,7 @@ describe('connectPanePty', () => { // Released (via the guard's fallback or parse completion): input flows again. deps.replayingPanesRef.current.delete(pane.id) sendTerminalInputThroughPane(pane, 'echo hi\r') - expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r') + expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r', 'query-reply') }) it('preserves classified user input during replay while suppressing synthetic replies', async () => { @@ -533,7 +533,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r') + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') // A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them. pane.terminal.buffer.active.type = 'alternate' @@ -544,7 +544,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r') + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') // The same bytes on the normal buffer can only be a keyboard arrow, which survives replay. pane.terminal.buffer.active.type = 'normal' @@ -556,7 +556,7 @@ describe('connectPanePty', () => { forward() } expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B', 'driving') // Once the guard releases, the same mouse report is ordinary input again. deps.replayingPanesRef.current.delete(pane.id) @@ -567,7 +567,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M', 'driving') }) it('settles a queued startup only after the pane binds its spawned PTY', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts index fd279a38f9d..7855e756226 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts @@ -738,7 +738,7 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') expect(mockStoreState.recordTerminalInput).not.toHaveBeenCalled() // The reply still reaches the shell; only the activity recording is gated. - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') // Real user input fires the core signal and records activity. for (const listener of userInputListeners) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts index e315563f0c6..ef49288f3eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts @@ -528,7 +528,7 @@ describe('connectPanePty', () => { 'terminal-bell' ) expect(deps.clearTerminalPaneUnread).not.toHaveBeenCalled() - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) // Why: xterm auto-replies during replay must not count as user interaction, or a BELed pane would self-dismiss unseen. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts index e61460ee490..c3221a7753b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts @@ -463,8 +463,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[I') sendTerminalInputThroughPane(pane, '\x7f') expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I') - expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I', 'query-reply') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f', 'query-reply') } finally { restoreUserAgent() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts index 4b1f376b773..7e9034c1d61 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts @@ -364,7 +364,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() expect(deps.updateTabTitle).not.toHaveBeenCalled() @@ -478,7 +478,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply') expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ paneKey, baselineUpdatedAt: 1_000, @@ -652,7 +652,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index 99c54dad2eb..1e8ad402de7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -275,8 +275,8 @@ describe('connectPanePty', () => { transport.sendInputImmediate.mockClear() sendTerminalInputThroughPane(pane, 'yes') sendTerminalInputThroughPane(pane, '\x1b[A') // arrow-key auto-repeat stays batched - expect(transport.sendInput).toHaveBeenCalledWith('yes') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A') + expect(transport.sendInput).toHaveBeenCalledWith('yes', 'query-reply') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A', 'query-reply') expect(transport.sendInputImmediate).not.toHaveBeenCalled() // terminal-query-reply.test proves real xterm emits this as one framed onData reply; this pins it to the immediate path. @@ -291,7 +291,7 @@ describe('connectPanePty', () => { const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] for (const data of printableInputs) { sendTerminalInputThroughPane(pane, data) - expect(transport.sendInput).toHaveBeenCalledWith(data) + expect(transport.sendInput).toHaveBeenCalledWith(data, 'query-reply') } expect(transport.sendInputImmediate).not.toHaveBeenCalled() }) @@ -368,7 +368,7 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') // Snapshot ends with ?25l (Cursor Agent parks/hides the cursor); the reset must preserve it, not force ?25h, or a stray block paints. expect(pane.terminal.write).toHaveBeenCalledWith( `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts index 559060108ee..a83236b5378 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts @@ -400,7 +400,7 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, expect.any(Function) ) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') return connection }) disposable.dispose() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts index c61fb188caf..92438968984 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts @@ -600,7 +600,7 @@ describe('connectPanePty', () => { writes.join('').indexOf('NEWER-LIVE-SSH-OUTPUT') ) expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r') - expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r') + expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r', 'query-reply') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId) }) @@ -642,7 +642,7 @@ describe('connectPanePty', () => { setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') expect(transport.claimViewport).toHaveBeenCalledTimes(1) ;(onDataHandler as (data: string) => void)('b') // The renderer stays parked until runtime convergence is acknowledged, so a second keystroke can retry a transient failed resize. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts index 2538c9e0cec..59fdc6decd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts @@ -222,7 +222,10 @@ describe('connectPanePty', () => { expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') expect(transport.sendInput).not.toHaveBeenCalledWith('droid\r') - expect(transport.sendInputAccepted).toHaveBeenCalledWith(`\x1b[200~${prompt}\x1b[201~`) + expect(transport.sendInputAccepted).toHaveBeenCalledWith( + `\x1b[200~${prompt}\x1b[201~`, + 'launch' + ) }) it('waits past 8s for a cold Codex composer and preserves input ordering', async () => { @@ -279,7 +282,8 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~', + 'launch' ) expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ '\x1b[I', @@ -354,7 +358,8 @@ describe('connectPanePty', () => { expect(transport.sendInputAccepted).toHaveBeenCalledTimes(1) expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~Linked Linear issue: STA-905\x1b[201~' + '\x1b[200~Linked Linear issue: STA-905\x1b[201~', + 'launch' ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts index 969bf836594..f85917e28c6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts @@ -525,7 +525,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(pane.terminal.paste).toHaveBeenCalledWith(command) - expect(transport.sendInput).toHaveBeenCalledWith('\r') + expect(transport.sendInput).toHaveBeenCalledWith('\r', 'launch') expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) } finally { globalThis.setTimeout = originalSetTimeout diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts index 4659fccb5c5..4a2d2ee1206 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts @@ -191,7 +191,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('uses the current worktree tab for Codex stale fallback without enumerating all worktrees', async () => { @@ -234,7 +234,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('blocks stale Codex fallback input from the current worktree tab without enumerating all worktrees', async () => { @@ -313,7 +313,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('restores input through the tab fallback when the dismissed pane has no live PTY binding', async () => { @@ -347,7 +347,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps a dismissed split pane typing while a sibling still holds the prompt', async () => { @@ -389,7 +389,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-dismissed') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps blocking a pane with its own unanswered notice next to a dismissed sibling', async () => { @@ -466,7 +466,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-plain-shell') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps blocking input on a pane whose restart is requested but not yet run', async () => { @@ -782,7 +782,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() vi.advanceTimersByTime(500) - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') expect(transport.sendInput).not.toHaveBeenCalled() expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) @@ -843,7 +843,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\r') // The terminator disarmed it, so the next real command reaches the shell. sendTerminalInputThroughPane(pane, 'ls\r') - expect(transport.sendInput).toHaveBeenCalledWith('ls\r') + expect(transport.sendInput).toHaveBeenCalledWith('ls\r', 'query-reply') _resetTerminalPaneRecoveryForTests() }) @@ -867,7 +867,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(transport.sendInput).toHaveBeenCalledWith('x', 'query-reply') expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) binding.dispose() @@ -892,7 +892,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) await flushAsyncTicks() - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) binding.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts index 119e61e121e..73105c0248a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts @@ -182,7 +182,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ @@ -436,7 +436,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -474,7 +474,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(transport.sendInput).toHaveBeenCalledWith('pi\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ @@ -525,7 +525,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -564,7 +564,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -650,7 +650,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r', 'query-reply') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', agentType: 'omp', diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts index 471e237a200..552f62c54bb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts @@ -254,7 +254,7 @@ describe('connectPanePty', () => { ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -311,7 +311,7 @@ describe('connectPanePty', () => { ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -568,7 +568,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -621,8 +621,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -667,8 +667,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x7f') sendTerminalInputThroughPane(pane, 'x') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -679,7 +679,7 @@ describe('connectPanePty', () => { transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -689,7 +689,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[O') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') } finally { restoreUserAgent() } @@ -725,8 +725,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) } finally { restoreUserAgent() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts index c6a25e0d461..417ccd812da 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -1,5 +1,6 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyConnectionDeps } from '../pty-connection-types' +import type { PtyTransport } from '../pty-transport-types' // Why: connectPanePty is a single closure factory. The bag lets extracted // installers share the same mutable bindings without changing call order. @@ -7,6 +8,8 @@ export type ConnectPanePtySession = { pane: ManagedPane manager: PaneManager deps: PtyConnectionDeps + // Why typed: every PTY write must name its input kind, and the bag's `any` would hide a missing one. + transport: PtyTransport // oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract [key: string]: any } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 6c3a0628f79..f2a91feb98c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -91,7 +91,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { return false } - return session.transport.sendInput('\r') + return session.transport.sendInput('\r', 'launch') } session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand @@ -116,7 +116,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio const command = startup.command const submitted = session.shouldDeliverStartupViaTerminalPaste ? await runTerminalPasteStartupCommand(command) - : session.transport.sendInput(`${command}\r`) + : session.transport.sendInput(`${command}\r`, 'launch') if (submitted) { session.armStartupDraftReadinessObservation() } else { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts index dff163f81d8..9c1b45051be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -151,6 +151,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi session.hiddenDeliverySyncedPtyId = null } if ( + !ptyId || !session.isHiddenDeliveryGateManagedPty(ptyId) || !session.canUseHiddenOutputSnapshot(ptyId) ) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index 9ca1202e9b1..8015e49dd11 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -122,16 +122,22 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { ) // Why: xterm focus reports share this transport queue. Bypassing it can // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. - void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => { - const accepted = await writeTerminalPastePtyInput(session.transport, data) - if (accepted && !startupDraftInputRecorded) { - // Why: this transport write bypasses xterm's user-input signal; keep - // the composed draft from being discarded by later hibernation. - startupDraftInputRecorded = true - session.recordTerminalInputForHibernation() + void sendAgentDraftPasteContent( + settings, + ptyId, + session.startupDraftPrompt, + 'launch', + async (data) => { + const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + session.recordTerminalInputForHibernation() + } + return accepted } - return accepted - }) + ) .catch(() => false) .finally(() => { startupDraftPasteInFlight = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 6ff7c244443..aad2384bb27 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -100,6 +100,8 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { session.clearPendingTerminalInputIntent() return } + // Why xterm's provenance: its own focus reports reach onData too, and no person typed them. + const inputKind = wasUserInput ? 'driving' : 'query-reply' const intent = session.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 @@ -123,7 +125,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { } session.clearPendingTerminalInputIntent() const writePromise = session.transport - .sendInputAccepted(data) + .sendInputAccepted(data, inputKind) .then((accepted): boolean | Promise | null => { if (accepted) { // Why: rejected writes use transport recovery and must not arm a parser probe. @@ -152,7 +154,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { } if (intent) { session.claimViewportForUserActivity() - if (session.transport.sendInput(data)) { + if (session.transport.sendInput(data, inputKind)) { session.markAcceptedTerminalInputSent() session.observeAcceptedShellCommandInput(data) session.observeAcceptedTerminalInput(data, intent) @@ -163,7 +165,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { return } session.claimViewportForUserActivity() - if (session.transport.sendInput(data)) { + if (session.transport.sendInput(data, inputKind)) { session.markAcceptedTerminalInputSent() session.observeAcceptedShellCommandInput(data) session.observeAcceptedTerminalInput(data) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 81ac85ed752..bd3d7284ac9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -63,7 +63,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { return } - session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE, 'query-reply') }) } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts index 6ac6068f068..b3a2236fe1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts @@ -1,3 +1,5 @@ +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64 export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS = @@ -8,6 +10,7 @@ export type PendingPtyInputWrite = { id: string text: string replyOnly: boolean + inputKind: TerminalInputKind resolveAccepted: ((accepted: boolean) => void) | undefined tooLarge: boolean | Promise chunks?: Iterator @@ -15,17 +18,17 @@ export type PendingPtyInputWrite = { } export type PtyInputWriteQueue = { - enqueue: (id: string, data: string) => boolean + enqueue: (id: string, data: string, inputKind: TerminalInputKind) => boolean enqueueQueryReply: (id: string, data: string) => boolean - enqueueAccepted: (id: string, data: string) => Promise + enqueueAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise waitForDrain: () => Promise clear: () => void } export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean - write: (id: string, data: string) => void - writeAccepted?: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted?: (id: string, data: string, inputKind: TerminalInputKind) => Promise yieldBetweenWrites?: () => Promise onDrainFailure?: (id: string) => void } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index 556ba19f8a6..9b3099d0326 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -81,7 +81,7 @@ describe('pty input write queue', () => { // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports // enqueued while the drain cannot run between events. for (let i = 0; i < 240; i += 1) { - expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT, 'driving')).toBe(true) } await queue.waitForDrain() @@ -98,7 +98,7 @@ describe('pty input write queue', () => { const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] for (const input of inputs) { - queue.enqueue('pty-1', input) + queue.enqueue('pty-1', input, 'driving') } await queue.waitForDrain() @@ -112,10 +112,10 @@ describe('pty input write queue', () => { write: (id, data) => writes.push({ id, data }) }) - queue.enqueue('pty-1', 'a') - queue.enqueue('pty-1', 'b') - queue.enqueue('pty-2', 'c') - queue.enqueue('pty-1', 'd') + queue.enqueue('pty-1', 'a', 'driving') + queue.enqueue('pty-1', 'b', 'driving') + queue.enqueue('pty-2', 'c', 'driving') + queue.enqueue('pty-1', 'd', 'driving') await queue.waitForDrain() expect(writes).toEqual([ @@ -131,7 +131,7 @@ describe('pty input write queue', () => { const piece = 'x'.repeat(1000) for (let i = 0; i < 12; i += 1) { - queue.enqueue('pty-1', piece) + queue.enqueue('pty-1', piece, 'driving') } await queue.waitForDrain() @@ -146,9 +146,9 @@ describe('pty input write queue', () => { const { writes, queue } = createRecordingQueue() const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) - queue.enqueue('pty-1', 'before') - queue.enqueue('pty-1', large) - queue.enqueue('pty-1', 'after') + queue.enqueue('pty-1', 'before', 'driving') + queue.enqueue('pty-1', large, 'driving') + queue.enqueue('pty-1', 'after', 'driving') await queue.waitForDrain() expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) @@ -161,7 +161,7 @@ describe('pty input write queue', () => { it('rejects input over the terminal input byte limit without writing', async () => { const { writes, queue } = createRecordingQueue() - expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving')).toBe(false) await queue.waitForDrain() expect(writes).toEqual([]) @@ -171,9 +171,9 @@ describe('pty input write queue', () => { let writable = true const { writes, queue } = createRecordingQueue({ writable: () => writable }) - queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'a', 'driving') writable = false - queue.enqueue('pty-1', 'b') + queue.enqueue('pty-1', 'b', 'driving') await queue.waitForDrain() expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) @@ -210,7 +210,7 @@ describe('pty input write queue', () => { const reply = mode2031SequenceFor('dark') queue.enqueueQueryReply('pty-1', reply) - queue.enqueue('pty-1', 'y') + queue.enqueue('pty-1', 'y', 'driving') await queue.waitForDrain() expect(writes).toEqual([ @@ -231,7 +231,7 @@ describe('pty input write queue', () => { allAccepted = queue.enqueueQueryReply('pty-1', reply) && allAccepted } expect(allAccepted).toBe(true) - expect(queue.enqueue('pty-1', 'k')).toBe(true) + expect(queue.enqueue('pty-1', 'k', 'driving')).toBe(true) await Promise.resolve() expect(pendingYields).toHaveLength(1) @@ -256,7 +256,7 @@ describe('pty input write queue', () => { for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) } - expect(queue.enqueue('pty-1', 'k')).toBe(true) + expect(queue.enqueue('pty-1', 'k', 'driving')).toBe(true) await Promise.resolve() for (let turn = 0; turn < PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; turn += 1) { @@ -286,10 +286,10 @@ describe('pty input write queue', () => { expect(second.length * 3).toBeGreaterThan(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS) queue.enqueueQueryReply('pty-1', first) queue.enqueueQueryReply('pty-1', dropped) - queue.enqueue('pty-1', 'x') + queue.enqueue('pty-1', 'x', 'driving') queue.enqueueQueryReply('pty-1', second) queue.enqueueQueryReply('pty-1', third) - queue.enqueue('pty-1', 'k') + queue.enqueue('pty-1', 'k', 'driving') await Promise.resolve() expect(pendingYields).toHaveLength(1) @@ -365,7 +365,7 @@ describe('pty input write queue', () => { const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) for (const input of ordinary) { - expect(queue.enqueue('pty-1', input)).toBe(true) + expect(queue.enqueue('pty-1', input, 'driving')).toBe(true) } for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) @@ -386,7 +386,7 @@ describe('pty input write queue', () => { const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) expect(queue.enqueueQueryReply('pty-1', '\x1b[?10000;1n')).toBe(true) - expect(queue.enqueue('pty-1', ordinary)).toBe(true) + expect(queue.enqueue('pty-1', ordinary, 'driving')).toBe(true) for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) } @@ -413,14 +413,14 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'stale')).toBe(true) - expect(queue.enqueue('pty-1', 'also-stale')).toBe(false) + expect(queue.enqueue('pty-1', 'stale', 'driving')).toBe(true) + expect(queue.enqueue('pty-1', 'also-stale', 'driving')).toBe(false) await expect(queue.waitForDrain()).resolves.toBeUndefined() shouldThrow = false - expect(queue.enqueue('pty-1', 'still-stale')).toBe(false) + expect(queue.enqueue('pty-1', 'still-stale', 'driving')).toBe(false) queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await queue.waitForDrain() expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }]) @@ -443,9 +443,9 @@ describe('pty input write queue', () => { } }) - expect(queue.enqueue('pty-1', 'first')).toBe(true) - const accepted = queue.enqueueAccepted('pty-1', 'second') - expect(queue.enqueue('pty-1', 'third')).toBe(true) + expect(queue.enqueue('pty-1', 'first', 'driving')).toBe(true) + const accepted = queue.enqueueAccepted('pty-1', 'second', 'driving') + expect(queue.enqueue('pty-1', 'third', 'driving')).toBe(true) expect(queue.enqueueQueryReply('pty-1', 'fourth')).toBe(true) await flushAsyncTicks() @@ -475,13 +475,13 @@ describe('pty input write queue', () => { delivered.push(`accepted:${data}`) if (!reentered) { reentered = true - queue.enqueue('pty-1', 'later') + queue.enqueue('pty-1', 'later', 'driving') } return acceptedWrite.promise } }) - const accepted = queue.enqueueAccepted('pty-1', 'first') + const accepted = queue.enqueueAccepted('pty-1', 'first', 'driving') await flushAsyncTicks() expect(delivered).toEqual(['accepted:first']) @@ -503,12 +503,12 @@ describe('pty input write queue', () => { writeAccepted: (_id, data) => { delivered.push(`accepted:${data}`) queue.clear() - queue.enqueue('pty-1', 'fresh') + queue.enqueue('pty-1', 'fresh', 'driving') return acceptedWrite.promise } }) - const accepted = queue.enqueueAccepted('pty-1', 'stale') + const accepted = queue.enqueueAccepted('pty-1', 'stale', 'driving') await expect(accepted).resolves.toBe(false) await queue.waitForDrain() @@ -533,12 +533,12 @@ describe('pty input write queue', () => { return acceptedWrite.promise } }) - const active = queue.enqueueAccepted('pty-1', 'stale-active') - const pending = queue.enqueueAccepted('pty-1', 'stale-pending') + const active = queue.enqueueAccepted('pty-1', 'stale-active', 'driving') + const pending = queue.enqueueAccepted('pty-1', 'stale-pending', 'driving') await acceptedStarted.promise queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await expect(active).resolves.toBe(false) await expect(pending).resolves.toBe(false) @@ -567,7 +567,9 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect( + queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2), 'driving') + ).toBe(true) expect(rejectYield).toBeDefined() rejectYield?.(failure) await queue.waitForDrain() @@ -607,11 +609,13 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect( + queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2), 'driving') + ).toBe(true) expect(rejectYield).toBeDefined() queue.clear() rejectNextYield = false - expect(queue.enqueue('pty-2', 'fresh')).toBe(true) + expect(queue.enqueue('pty-2', 'fresh', 'driving')).toBe(true) rejectYield?.(failure) await queue.waitForDrain() @@ -697,8 +701,8 @@ describe('pty input write queue', () => { }) const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) - queue.enqueue('pty-1', large) - queue.enqueue('pty-1', 'tail') + queue.enqueue('pty-1', large, 'driving') + queue.enqueue('pty-1', 'tail', 'driving') // First chunk is written synchronously, then the drain parks on the yield. expect(writes.length).toBe(1) @@ -716,9 +720,9 @@ describe('pty input write queue', () => { const stale = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) try { - expect(queue.enqueue('pty-1', stale)).toBe(true) + expect(queue.enqueue('pty-1', stale, 'driving')).toBe(true) queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await vi.runAllTimersAsync() await queue.waitForDrain() @@ -755,7 +759,7 @@ describe('pty input write queue', () => { globalThis.gc?.() const heapBefore = process.memoryUsage().heapUsed for (let index = 0; index < 20_000; index += 1) { - await queue.enqueueAccepted('pty-1', 'x') + await queue.enqueueAccepted('pty-1', 'x', 'driving') } await queue.waitForDrain() globalThis.gc?.() diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 31b37ed6af5..7f646354b27 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -12,6 +12,7 @@ import { type PtyInputWriteQueue, type PtyInputWriteQueueDeps } from './pty-input-write-queue-contract' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { createHeadQueue, peekHeadQueue, @@ -111,7 +112,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput // Why: one cancel per in-flight write rather than `.then()` on a queue-lifetime // promise — those reactions are retained until that promise settles, so a // long-lived pane accumulated one record per acknowledged write (Esc, Ctrl+C). - async function writeAcceptedChunk(id: string, data: string): Promise { + async function writeAcceptedChunk(item: PendingPtyInputWrite, data: string): Promise { let cancel = (): void => undefined const cancelled = new Promise((resolve) => { cancel = () => resolve(false) @@ -122,7 +123,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput try { return await Promise.race([ cancelled, - Promise.resolve(deps.writeAccepted?.(id, data) ?? false).catch(() => false) + Promise.resolve(deps.writeAccepted?.(item.id, data, item.inputKind) ?? false).catch( + () => false + ) ]) } finally { pendingAcceptedCancels.delete(cancel) @@ -173,6 +176,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput peek.tooLarge !== false || peek.chunks !== undefined || !isCoalesciblePtyInput(peek) || + peek.inputKind !== next.inputKind || payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS ) { break @@ -180,7 +184,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput payload += peek.text removePending(peek) } - deps.write(next.id, payload) + deps.write(next.id, payload, next.inputKind) if (firstPending()) { await yieldBetweenWrites() } @@ -196,8 +200,8 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } const writeGeneration = generation const accepted = next.resolveAccepted - ? await writeAcceptedChunk(next.id, chunk.value) - : (deps.write(next.id, chunk.value), true) + ? await writeAcceptedChunk(next, chunk.value) + : (deps.write(next.id, chunk.value, next.inputKind), true) if (generation !== writeGeneration || firstPending() !== next) { continue } @@ -249,8 +253,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput function enqueueInput( id: string, - data: string, + text: string, queryReply: boolean, + inputKind: TerminalInputKind, resolveAccepted?: PendingPtyInputWrite['resolveAccepted'] ): boolean { try { @@ -260,20 +265,28 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } // Every query reply stays atomic so host-side ordering can classify it (#13892). const replyOnly = queryReply - if (replyOnly && !admitReply(data)) { + if (replyOnly && !admitReply(text)) { return false } - const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(data) + const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(text) if (tooLarge === true) { resolveAccepted?.(false) return false } - const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge, resolveAccepted } + const item = { + sequence: nextSequence, + id, + text, + replyOnly, + inputKind, + tooLarge, + resolveAccepted + } nextSequence += 1 if (replyOnly) { pendingReplies.items.push(item) pendingReplyCount += 1 - pendingReplyCodeUnits += data.length + pendingReplyCodeUnits += text.length } else { pendingOrdinary.items.push(item) } @@ -286,17 +299,17 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } return { - enqueue(id: string, data: string): boolean { - return enqueueInput(id, data, false) + enqueue(id: string, data: string, inputKind: TerminalInputKind): boolean { + return enqueueInput(id, data, false, inputKind) }, enqueueQueryReply(id: string, data: string): boolean { - return enqueueInput(id, data, true) + return enqueueInput(id, data, true, 'query-reply') }, - enqueueAccepted: (id, data) => + enqueueAccepted: (id, data, inputKind) => new Promise((resolve) => { - enqueueInput(id, data, false, resolve) + enqueueInput(id, data, false, inputKind, resolve) }), async waitForDrain(): Promise { diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts index d36c3a274ed..2595863cb3d 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts @@ -11,9 +11,9 @@ describe('createPtyPreconnectInputBuffer', () => { const acceptedWrite = createDeferred() const buffer = createPtyPreconnectInputBuffer() const delivered: string[] = [] - const accepted = buffer.enqueueAccepted('first') - expect(buffer.enqueue('second', 'ordinary')).toBe(true) - expect(buffer.enqueue('third', 'immediate')).toBe(true) + const accepted = buffer.enqueueAccepted('first', 'driving') + expect(buffer.enqueue('second', 'ordinary', 'driving')).toBe(true) + expect(buffer.enqueue('third', 'immediate', 'driving')).toBe(true) const writer = { isCurrent: () => true, sendInput: (data: string) => { @@ -52,9 +52,9 @@ describe('createPtyPreconnectInputBuffer', () => { const writeStarted = createDeferred() const buffer = createPtyPreconnectInputBuffer() const sendInput = vi.fn(() => true) - const accepted = buffer.enqueueAccepted('first') - const laterAccepted = buffer.enqueueAccepted('third') - expect(buffer.enqueue('second', 'ordinary')).toBe(true) + const accepted = buffer.enqueueAccepted('first', 'driving') + const laterAccepted = buffer.enqueueAccepted('third', 'driving') + expect(buffer.enqueue('second', 'ordinary', 'driving')).toBe(true) const flushing = buffer.flush({ isCurrent: () => true, sendInput, @@ -72,7 +72,7 @@ describe('createPtyPreconnectInputBuffer', () => { await expect(laterAccepted).resolves.toBe(false) await expect(flushing).resolves.toBeUndefined() expect(sendInput).not.toHaveBeenCalled() - expect(buffer.enqueue('after-clear', 'ordinary')).toBe(false) + expect(buffer.enqueue('after-clear', 'ordinary', 'driving')).toBe(false) if (lateOutcome === 'resolve') { acceptedWrite.resolve(true) @@ -91,7 +91,8 @@ describe('createPtyPreconnectInputBuffer', () => { const codeUnitWriteStarted = createDeferred() const codeUnitBuffer = createPtyPreconnectInputBuffer() const codeUnitAccepted = codeUnitBuffer.enqueueAccepted( - 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS) + 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + 'driving' ) const codeUnitFlush = codeUnitBuffer.flush({ isCurrent: () => true, @@ -104,7 +105,7 @@ describe('createPtyPreconnectInputBuffer', () => { }) await codeUnitWriteStarted.promise - expect(codeUnitBuffer.enqueue('overflow', 'ordinary')).toBe(false) + expect(codeUnitBuffer.enqueue('overflow', 'ordinary', 'driving')).toBe(false) codeUnitBuffer.clear() codeUnitWrite.resolve(true) @@ -114,9 +115,9 @@ describe('createPtyPreconnectInputBuffer', () => { const entryWrite = createDeferred() const entryWriteStarted = createDeferred() const entryBuffer = createPtyPreconnectInputBuffer() - const entryAccepted = entryBuffer.enqueueAccepted('first') + const entryAccepted = entryBuffer.enqueueAccepted('first', 'driving') for (let index = 1; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - expect(entryBuffer.enqueue('', 'ordinary')).toBe(true) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(true) } const entryFlush = entryBuffer.flush({ isCurrent: () => true, @@ -129,7 +130,7 @@ describe('createPtyPreconnectInputBuffer', () => { }) await entryWriteStarted.promise - expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(false) entryBuffer.clear() entryWrite.resolve(true) @@ -141,12 +142,13 @@ describe('createPtyPreconnectInputBuffer', () => { const entryBuffer = createPtyPreconnectInputBuffer( Array.from({ length: PTY_PRECONNECT_INPUT_MAX_ENTRIES + 1 }, () => ({ data: '', - kind: 'ordinary' as const + kind: 'ordinary' as const, + inputKind: 'driving' as const })) ) let entryWrites = 0 - expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(false) await entryBuffer.flush({ isCurrent: () => true, sendInput: () => { @@ -158,12 +160,16 @@ describe('createPtyPreconnectInputBuffer', () => { expect(entryWrites).toBe(PTY_PRECONNECT_INPUT_MAX_ENTRIES) const codeUnitBuffer = createPtyPreconnectInputBuffer([ - { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' }, - { data: 'overflow', kind: 'ordinary' } + { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary', + inputKind: 'driving' + }, + { data: 'overflow', kind: 'ordinary', inputKind: 'driving' } ]) const codeUnitWrites: number[] = [] - expect(codeUnitBuffer.enqueue('new', 'ordinary')).toBe(false) + expect(codeUnitBuffer.enqueue('new', 'ordinary', 'driving')).toBe(false) await codeUnitBuffer.flush({ isCurrent: () => true, sendInput: (data) => { @@ -179,7 +185,7 @@ describe('createPtyPreconnectInputBuffer', () => { // Regression: an abandoned deferred spawn never reaches connect(), so nothing // drained the buffer and a paste awaiting sendInputAccepted hung forever. const buffer = createPtyPreconnectInputBuffer() - const pastePending = buffer.enqueueAccepted('pasted text') + const pastePending = buffer.enqueueAccepted('pasted text', 'driving') let settled = false void pastePending.then(() => { settled = true diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts index 3dce1962bb9..cb8dec2d6d0 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts @@ -1,4 +1,5 @@ import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' export const PTY_PRECONNECT_INPUT_MAX_ENTRIES = 1024 // Why: a retention budget, not the 16MB single-write ceiling. The deferral lasts @@ -12,6 +13,7 @@ export type PtyPreconnectInputKind = 'ordinary' | 'immediate' | 'accepted' export type PtyPreconnectInputEntry = { data: string kind: PtyPreconnectInputKind + inputKind: TerminalInputKind } type BufferedInput = PtyPreconnectInputEntry & { @@ -20,9 +22,9 @@ type BufferedInput = PtyPreconnectInputEntry & { type PreconnectInputWriter = { isCurrent: () => boolean - sendInput: (data: string) => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, inputKind: TerminalInputKind) => Promise } export type PtyPreconnectInputBuffer = { @@ -30,10 +32,12 @@ export type PtyPreconnectInputBuffer = { enqueue: ( data: string, kind: 'ordinary' | 'immediate', + inputKind: TerminalInputKind, onRetained?: (entry: PtyPreconnectInputEntry) => void ) => boolean enqueueAccepted: ( data: string, + inputKind: TerminalInputKind, onRetained?: (entry: PtyPreconnectInputEntry) => void ) => Promise flush: (writer: PreconnectInputWriter) => Promise @@ -70,14 +74,15 @@ export function createPtyPreconnectInputBuffer( const createInput = ( data: string, kind: PtyPreconnectInputKind, + inputKind: TerminalInputKind, resolve?: BufferedInput['resolve'] - ): BufferedInput => ({ data, kind, ...(resolve ? { resolve } : {}) }) + ): BufferedInput => ({ data, kind, inputKind, ...(resolve ? { resolve } : {}) }) const notifyRetained = ( onRetained: ((entry: PtyPreconnectInputEntry) => void) | undefined, input: BufferedInput ): void => { try { - onRetained?.({ data: input.data, kind: input.kind }) + onRetained?.({ data: input.data, kind: input.kind, inputKind: input.inputKind }) } catch { // Handoff capture is advisory; a callback failure must not reject input admission. } @@ -86,7 +91,7 @@ export function createPtyPreconnectInputBuffer( // Seeded entries came from a predecessor transport and must not be reported // back to that predecessor's handoff owner as newly typed input. for (const entry of initialEntries) { - retain(createInput(entry.data, entry.kind)) + retain(createInput(entry.data, entry.kind, entry.inputKind)) } const clear = (): void => { const dropped = pending @@ -124,8 +129,8 @@ export function createPtyPreconnectInputBuffer( accepted = await Promise.race([ Promise.resolve( writer.sendInputAccepted - ? writer.sendInputAccepted(input.data) - : writer.sendInput(input.data) + ? writer.sendInputAccepted(input.data, input.inputKind) + : writer.sendInput(input.data, input.inputKind) ), flushStopped.then(() => null) ]) @@ -152,7 +157,7 @@ export function createPtyPreconnectInputBuffer( const accepted = input.kind === 'immediate' ? writer.sendInputImmediate(input.data) - : writer.sendInput(input.data) + : writer.sendInput(input.data, input.inputKind) if (!accepted) { clear() return @@ -186,17 +191,17 @@ export function createPtyPreconnectInputBuffer( return { isBuffering: () => buffering, - enqueue(data, kind, onRetained) { - const input = createInput(data, kind) + enqueue(data, kind, inputKind, onRetained) { + const input = createInput(data, kind, inputKind) const retained = retain(input) if (retained) { notifyRetained(onRetained, input) } return retained }, - enqueueAccepted(data, onRetained) { + enqueueAccepted(data, inputKind, onRetained) { return new Promise((resolve) => { - const input = createInput(data, 'accepted', resolve) + const input = createInput(data, 'accepted', inputKind, resolve) if (!retain(input)) { resolve(false) return diff --git a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts index 1060d617dd2..df2c11e08d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts @@ -65,8 +65,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: { onWriteUnavailable: recovery } }) - expect(transport.sendInput('input')).toBe(true) - expect(transport.sendInput('later-input')).toBe(false) + expect(transport.sendInput('input', 'driving')).toBe(true) + expect(transport.sendInput('later-input', 'driving')).toBe(false) expect(recovery).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure) @@ -80,8 +80,8 @@ describe('createIpcPtyTransport', () => { 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') + await expect(localTransport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03', 'driving') const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' }) await sshTransport.connect({ url: '', callbacks: {} }) @@ -103,8 +103,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first-')).toBe(true) - const accepted = transport.sendInputAccepted?.('second-') + expect(transport.sendInput('first-', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second-', 'driving') expect(transport.sendInputImmediate('third')).toBe(true) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -133,21 +133,21 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true, preconnectInput: [ - { data: 'before-remount-', kind: 'ordinary' }, - { data: '\x1b[0n', kind: 'immediate' }, - { data: '\x03', kind: 'accepted' } + { data: 'before-remount-', kind: 'ordinary', inputKind: 'driving' }, + { data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' }, + { data: '\x03', kind: 'accepted', inputKind: 'driving' } ], onPreconnectInput }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('new-ordinary-')).toBe(true) + expect(transport.sendInput('new-ordinary-', 'driving')).toBe(true) expect(transport.sendInputImmediate('new-immediate-')).toBe(true) - const accepted = transport.sendInputAccepted?.('new-accepted') + const accepted = transport.sendInputAccepted?.('new-accepted', 'driving') expect(onPreconnectInput.mock.calls).toEqual([ - [{ data: 'new-ordinary-', kind: 'ordinary' }], - [{ data: 'new-immediate-', kind: 'immediate' }], - [{ data: 'new-accepted', kind: 'accepted' }] + [{ data: 'new-ordinary-', kind: 'ordinary', inputKind: 'driving' }], + [{ data: 'new-immediate-', kind: 'immediate', inputKind: 'query-reply' }], + [{ data: 'new-accepted', kind: 'accepted', inputKind: 'driving' }] ]) spawn.resolve({ id: 'pty-1' }) @@ -180,8 +180,8 @@ describe('createIpcPtyTransport', () => { onPreconnectInput: (input) => captured.push(input) }) - const predecessorAccepted = predecessor.sendInputAccepted?.('\x03') - expect(captured).toEqual([{ data: '\x03', kind: 'accepted' }]) + const predecessorAccepted = predecessor.sendInputAccepted?.('\x03', 'driving') + expect(captured).toEqual([{ data: '\x03', kind: 'accepted', inputKind: 'driving' }]) const successor = createIpcPtyTransport({ preconnectInput: captured }) const connecting = successor.connect({ url: '', callbacks: {} }) @@ -205,17 +205,17 @@ describe('createIpcPtyTransport', () => { onPreconnectInput }) - expect(transport.sendInput('ordinary')).toBe(true) + expect(transport.sendInput('ordinary', 'driving')).toBe(true) expect(transport.sendInputImmediate('immediate')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + const accepted = transport.sendInputAccepted?.('accepted', 'driving') expect(onPreconnectInput).toHaveBeenCalledTimes(3) await transport.connect({ url: '', callbacks: {} }) await expect(accepted).resolves.toBe(true) - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary') - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate') - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary', 'driving') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate', 'query-reply') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted', 'driving') }) it('keeps live acknowledged input ahead of later ordinary and immediate writes', async () => { @@ -235,9 +235,9 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') - expect(transport.sendInput('third')).toBe(true) + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') + expect(transport.sendInput('third', 'driving')).toBe(true) expect(transport.sendInputImmediate('fourth')).toBe(true) await flushAsyncTicks() @@ -275,11 +275,11 @@ describe('createIpcPtyTransport', () => { const slowInput = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(slowInput)).toBe(true) + expect(transport.sendInput(slowInput, 'driving')).toBe(true) await connecting - const accepted = transport.sendInputAccepted?.('accepted') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) expect(transport.sendInputImmediate('reply')).toBe(true) expect(delivered).toEqual([]) @@ -318,14 +318,14 @@ describe('createIpcPtyTransport', () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - expect(transport.sendInput('ordinary')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + expect(transport.sendInput('ordinary', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') await transport.destroy?.() await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-destroy')).toBe(false) + expect(transport.sendInput('after-destroy', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-destroy')).toBe(false) - await expect(transport.sendInputAccepted?.('after-destroy')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-destroy', 'driving')).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) @@ -341,11 +341,11 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.('first') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('first', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) spawn.resolve({ id: 'pty-1' }) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first', 'driving') if (teardown === 'disconnect') { transport.disconnect() @@ -358,7 +358,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) await expect(connecting).resolves.toBe('pty-1') expect(window.api.pty.write).not.toHaveBeenCalled() - expect(transport.sendInput('after-teardown')).toBe(false) + expect(transport.sendInput('after-teardown', 'driving')).toBe(false) acceptedWrite.resolve(true) await flushAsyncTicks() @@ -377,10 +377,10 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.('first') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('first', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first', 'driving') if (teardown === 'disconnect') { transport.disconnect() @@ -392,7 +392,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() - expect(transport.sendInput('after-teardown')).toBe(false) + expect(transport.sendInput('after-teardown', 'driving')).toBe(false) acceptedWrite.resolve(true) await flushAsyncTicks() @@ -412,7 +412,7 @@ describe('createIpcPtyTransport', () => { const onConnect = vi.fn() const connecting = transport.connect({ url: '', callbacks: { onConnect } }) - const accepted = transport.sendInputAccepted?.('pending') + const accepted = transport.sendInputAccepted?.('pending', 'driving') if (teardown === 'disconnect') { transport.disconnect() } else { @@ -551,18 +551,18 @@ describe('createIpcPtyTransport', () => { const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(`${chunk}${chunk}`)).toBe(true) - expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(transport.sendInput(`${chunk}${chunk}`, 'driving')).toBe(true) + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') onExit?.({ id: 'pty-1', code: 0 }) transport.attach({ existingPtyId: 'pty-1', callbacks: {} }) - expect(transport.sendInput('fresh')).toBe(true) + expect(transport.sendInput('fresh', 'driving')).toBe(true) await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', chunk], - ['pty-1', 'fresh'] + ['pty-1', chunk, 'driving'], + ['pty-1', 'fresh', 'driving'] ]) } finally { vi.useRealTimers() @@ -577,22 +577,22 @@ describe('createIpcPtyTransport', () => { const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`) + const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`, 'driving') await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') onExit?.({ id: 'pty-1', code: 0 }) transport.attach({ existingPtyId: 'pty-1', callbacks: {} }) - expect(transport.sendInput('fresh')).toBe(true) + expect(transport.sendInput('fresh', 'driving')).toBe(true) await expect(accepted).resolves.toBe(false) await flushAsyncTicks() - expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh') + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh', 'driving') acceptedWrite.resolve(true) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') await expect(accepted).resolves.toBe(false) }) @@ -601,13 +601,13 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) expect(transport.sendInputImmediate('immediate')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + const accepted = transport.sendInputAccepted?.('accepted', 'driving') transport.disconnect() await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-disconnect')).toBe(false) + expect(transport.sendInput('after-disconnect', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-disconnect')).toBe(false) - await expect(transport.sendInputAccepted?.('after-disconnect')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-disconnect', 'driving')).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) @@ -619,13 +619,13 @@ describe('createIpcPtyTransport', () => { bufferPreHandlerPtyExit(ptyId, 0) const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - const accepted = transport.sendInputAccepted?.('input') + const accepted = transport.sendInputAccepted?.('input', 'driving') transport.attach({ existingPtyId: ptyId, callbacks: {} }) await expect(accepted).resolves.toBe(false) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('after-exit')).toBe(false) + expect(transport.sendInput('after-exit', 'driving')).toBe(false) clearPreHandlerPtyState(ptyId) }) @@ -635,7 +635,7 @@ describe('createIpcPtyTransport', () => { }) const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - const accepted = transport.sendInputAccepted?.('input') + const accepted = transport.sendInputAccepted?.('input', 'driving') expect(() => transport.attach({ existingPtyId: 'pty-attach-failure', callbacks: {} })).toThrow( 'dispatcher attach failed' @@ -643,7 +643,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('after-failure')).toBe(false) + expect(transport.sendInput('after-failure', 'driving')).toBe(false) }) it('settles and drops preconnect input when the split spawn fails', async () => { @@ -652,14 +652,14 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const onError = vi.fn() - expect(transport.sendInput('ordinary')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + expect(transport.sendInput('ordinary', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') await transport.connect({ url: '', callbacks: { onError } }) await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-failure')).toBe(false) + expect(transport.sendInput('after-failure', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-failure')).toBe(false) - await expect(transport.sendInputAccepted?.('after-failure')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-failure', 'driving')).resolves.toBe(false) expect(onError).toHaveBeenCalledWith('spawn failed') expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -673,16 +673,16 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') - expect(transport.sendInput('third')).toBe(true) + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') + expect(transport.sendInput('third', 'driving')).toBe(true) spawn.resolve({ id: 'pty-1' }) await connecting await expect(accepted).resolves.toBe(false) expect(window.api.pty.write).toHaveBeenCalledOnce() - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first', 'driving') }) it('drops acknowledged preconnect input when an earlier ordinary write fails', async () => { @@ -695,8 +695,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) try { - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') await transport.connect({ url: '', callbacks: {} }) await expect(accepted).resolves.toBe(false) @@ -712,12 +712,14 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) } - expect(transport.sendInput('overflow')).toBe(false) + expect(transport.sendInput('overflow', 'driving')).toBe(false) const oversized = createIpcPtyTransport({ bufferInputUntilConnect: true }) - expect(oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1))).toBe(false) + expect( + oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1), 'driving') + ).toBe(false) await transport.destroy?.() await oversized.destroy?.() }) @@ -731,14 +733,14 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(`${chunk}tail`)).toBe(true) + expect(transport.sendInput(`${chunk}tail`, 'driving')).toBe(true) expect(window.api.pty.write).toHaveBeenCalledTimes(1) - expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk, 'driving') await vi.runOnlyPendingTimersAsync() expect(window.api.pty.write).toHaveBeenCalledTimes(2) - expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', 'driving') } finally { vi.useRealTimers() } @@ -755,7 +757,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) expect(transport.sendInputImmediate(first)).toBe(true) - expect(transport.sendInput(ordinary)).toBe(true) + expect(transport.sendInput(ordinary, 'driving')).toBe(true) for (const reply of replies) { expect(transport.sendInputImmediate(reply)).toBe(true) } @@ -763,11 +765,11 @@ describe('createIpcPtyTransport', () => { await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', first], - ['pty-1', ordinary], + ['pty-1', first, 'query-reply'], + ['pty-1', ordinary, 'driving'], ...replies .slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) - .map((reply) => ['pty-1', reply]) + .map((reply) => ['pty-1', reply, 'query-reply']) ]) } finally { vi.useRealTimers() @@ -783,7 +785,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(text)).toBe(true) + expect(transport.sendInput(text, 'driving')).toBe(true) expect(window.api.pty.write).not.toHaveBeenCalled() await vi.runAllTimersAsync() @@ -805,7 +807,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + expect(transport.sendInput('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving')).toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() }) @@ -818,16 +820,16 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(`${chunk}tail`) + const accepted = transport.sendInputAccepted?.(`${chunk}tail`, 'driving') await Promise.resolve() expect(window.api.pty.writeAccepted).toHaveBeenCalledTimes(1) - expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(1, 'pty-1', chunk, 'driving') await vi.runOnlyPendingTimersAsync() await expect(accepted).resolves.toBe(true) expect(window.api.pty.writeAccepted).toHaveBeenCalledTimes(2) - expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', 'driving') } finally { vi.useRealTimers() } @@ -842,7 +844,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(text) + const accepted = transport.sendInputAccepted?.(text, 'driving') await Promise.resolve() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -867,7 +869,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) await expect( - transport.sendInputAccepted?.('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)) + transport.sendInputAccepted?.('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving') ).resolves.toBe(false) expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts index 7eeba23ec8d..b10fd3fe1ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts @@ -92,7 +92,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: { onError } }) expect(onError).toHaveBeenCalled() expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('echo hello\r')).toBe(false) + expect(transport.sendInput('echo hello\r', 'driving')).toBe(false) await flushPtySideEffects() expect(write).not.toHaveBeenCalled() @@ -102,7 +102,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: { onError: onErrorKilled } }) expect(onErrorKilled).not.toHaveBeenCalled() expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('echo hello\r')).toBe(false) + expect(transport.sendInput('echo hello\r', 'driving')).toBe(false) await flushPtySideEffects() expect(write).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 3b835b9d0a2..7fd0de6952e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -10,6 +10,7 @@ import type { import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' import type { TuiAgent } from '../../../../shared/tui-agent' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -182,7 +183,7 @@ export type PtyTransport = { callbacks: PtyCallbacks }) => void disconnect: () => void - sendInput: (data: string) => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean // Why: latency-critical terminal query replies (CPR/DSR/DA/OSC color/pixel // size) must skip input coalescing — a querying program reads them in raw // mode with a short timeout, so a debounced reply lands on the shell prompt @@ -190,7 +191,7 @@ export type PtyTransport = { // this is `sendInput` for them; the remote transport flushes pending input // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, inputKind: TerminalInputKind) => Promise /** Settles retained pre-connect input when a deferred spawn is abandoned before connect. */ abandonPreconnectInput?: () => void claimViewport?: (cols: number, rows: number) => boolean diff --git a/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts new file mode 100644 index 00000000000..34a71b81b8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' +import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' + +describe('createIpcPtyTransport: input kind', () => { + const originalWindow: typeof window | undefined = globalThis.window + + beforeEach(() => { + vi.resetModules() + installIpcPtyWindow(originalWindow, {}) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + it('sends every write with the kind its writer gave it', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + transport.sendInput('typed', 'driving') + await flushAsyncTicks() + transport.sendInput('\x1b[I', 'query-reply') + await flushAsyncTicks() + transport.sendInputImmediate('\x1b[1;1R') + await flushAsyncTicks() + await transport.sendInputAccepted?.('echo startup\r', 'launch') + + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', 'typed', 'driving'], + ['pty-1', '\x1b[I', 'query-reply'], + ['pty-1', '\x1b[1;1R', 'query-reply'] + ]) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'echo startup\r', 'launch') + transport.disconnect() + }) + + it('never merges bytes of different kinds', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + // The first write drains at once; what queues behind it would otherwise coalesce. + transport.sendInput('\x1b[O', 'query-reply') + transport.sendInput('\x1b[I', 'query-reply') + transport.sendInput('a', 'driving') + transport.sendInput('b', 'driving') + + await vi.waitFor(() => + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', '\x1b[O', 'query-reply'], + ['pty-1', '\x1b[I', 'query-reply'], + ['pty-1', 'ab', 'driving'] + ]) + ) + transport.disconnect() + }) + + it('keeps the kind of input written before the spawn connects and across a remount', async () => { + const spawn = createDeferred>>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise) + const { createIpcPtyTransport } = await import('./pty-transport') + const captured: PtyPreconnectInputEntry[] = [] + const predecessor = createIpcPtyTransport({ + bufferInputUntilConnect: true, + onPreconnectInput: (entry) => captured.push(entry) + }) + void predecessor.connect({ url: '', callbacks: {} }) + predecessor.sendInput('typed-early', 'driving') + predecessor.sendInput('startup', 'launch') + predecessor.destroy?.() + + const successor = createIpcPtyTransport({ preconnectInput: captured }) + spawn.resolve({ id: 'pty-1' }) + await successor.connect({ url: '', callbacks: {} }) + await flushAsyncTicks() + + expect(captured).toEqual([ + { data: 'typed-early', kind: 'ordinary', inputKind: 'driving' }, + { data: 'startup', kind: 'ordinary', inputKind: 'launch' } + ]) + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', 'typed-early', 'driving'], + ['pty-1', 'startup', 'launch'] + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index a8adfccb7ac..fd26a7324b5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -5,6 +5,7 @@ import { createPtyInputWriteQueue } from './pty-input-write-queue' import { createPtyOutputProcessor } from './pty-output-processor' import { createPtyPreconnectInputBuffer } from './pty-preconnect-input-buffer' import type { IpcPtyTransportOptions, PtyTransport } from './pty-transport-types' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' export { ensurePtyDispatcher, @@ -57,8 +58,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => !destroyed && connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data), - writeAccepted: (id, data) => window.api.pty.writeAccepted(id, data), + write: (id, data, inputKind) => window.api.pty.write(id, data, inputKind), + writeAccepted: (id, data, inputKind) => window.api.pty.writeAccepted(id, data, inputKind), onDrainFailure: (id) => { if (ptyId === id) { storedCallbacks.onWriteUnavailable?.() @@ -115,12 +116,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } await preconnectInputBuffer.flush({ isCurrent: () => !destroyed && connected && ptyId === id, - sendInput: (data) => inputWriteQueue.enqueue(id, data), + sendInput: (data, inputKind) => inputWriteQueue.enqueue(id, data, inputKind), sendInputImmediate: (data) => inputWriteQueue.enqueueQueryReply(id, data), ...(connectionId ? {} : { - sendInputAccepted: (data: string) => inputWriteQueue.enqueueAccepted(id, data) + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => + inputWriteQueue.enqueueAccepted(id, data, inputKind) }) }) } @@ -213,16 +215,23 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks = {} }, - sendInput(data) { + sendInput(data, inputKind) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'ordinary', opts.onPreconnectInput) + return preconnectInputBuffer.enqueue(data, 'ordinary', inputKind, opts.onPreconnectInput) } - return !destroyed && connected && ptyId ? inputWriteQueue.enqueue(ptyId, data) : false + return !destroyed && connected && ptyId + ? inputWriteQueue.enqueue(ptyId, data, inputKind) + : false }, sendInputImmediate(data) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'immediate', opts.onPreconnectInput) + return preconnectInputBuffer.enqueue( + data, + 'immediate', + 'query-reply', + opts.onPreconnectInput + ) } return !destroyed && connected && ptyId ? inputWriteQueue.enqueueQueryReply(ptyId, data) @@ -232,14 +241,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(connectionId ? {} : { - async sendInputAccepted(data: string): Promise { + async sendInputAccepted(data: string, inputKind: TerminalInputKind): Promise { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueueAccepted(data, opts.onPreconnectInput) + return preconnectInputBuffer.enqueueAccepted(data, inputKind, opts.onPreconnectInput) } if (destroyed || !connected || !ptyId) { return false } - return inputWriteQueue.enqueueAccepted(ptyId, data) + return inputWriteQueue.enqueueAccepted(ptyId, data, inputKind) } }), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts index 1df9a355a05..b07afbb20d3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts @@ -48,18 +48,18 @@ describe('remote runtime error surface dismissal', () => { const onError = vi.fn() const transport = await attachTransportWithFatalSends(onError) - expect(await transport.sendInputAccepted?.('a')).toBe(false) - expect(await transport.sendInputAccepted?.('b')).toBe(false) + expect(await transport.sendInputAccepted?.('a', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('b', 'driving')).toBe(false) // Contract preserved: one continuous outage still surfaces the repeated error once. expect(onError.mock.calls).toEqual([[FATAL_ERROR]]) transport.notifyErrorSurfaceDismissed?.() - expect(await transport.sendInputAccepted?.('c')).toBe(false) + expect(await transport.sendInputAccepted?.('c', 'driving')).toBe(false) expect(onError.mock.calls).toEqual([[FATAL_ERROR], [FATAL_ERROR]]) // The memory re-arms: repeats after the re-surfaced error are suppressed again until the next dismissal. - expect(await transport.sendInputAccepted?.('d')).toBe(false) + expect(await transport.sendInputAccepted?.('d', 'driving')).toBe(false) expect(onError).toHaveBeenCalledTimes(2) transport.destroy?.() }) @@ -68,9 +68,9 @@ describe('remote runtime error surface dismissal', () => { const onError = vi.fn() const transport = await attachTransportWithFatalSends(onError) - expect(await transport.sendInputAccepted?.('a')).toBe(false) - expect(await transport.sendInputAccepted?.('b')).toBe(false) - expect(await transport.sendInputAccepted?.('c')).toBe(false) + expect(await transport.sendInputAccepted?.('a', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('b', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('c', 'driving')).toBe(false) expect(onError).toHaveBeenCalledTimes(1) transport.destroy?.() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts index ec46891e9d5..6d8616a1baa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts @@ -71,7 +71,7 @@ describe('remote transport sendInputImmediate (#7329)', () => { await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) // Typed input: debounced — nothing sent before the 8ms flush. - expect(transport.sendInput('yes')).toBe(true) + expect(transport.sendInput('yes', 'driving')).toBe(true) expect(terminalSendCalls()).toEqual([]) // Query reply (OSC 11 background color): sent immediately, no timer. @@ -139,10 +139,10 @@ describe('remote transport sendInputImmediate (#7329)', () => { // A paste above CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS forces the async // validation path, so its bytes are captured in validationTail, not pending. const paste = 'p'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect(transport.sendInput(paste)).toBe(true) + expect(transport.sendInput(paste, 'driving')).toBe(true) // Immediately (validation still pending) a TUI emits a CPR reply. expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) - expect(transport.sendInput('z')).toBe(true) + expect(transport.sendInput('z', 'driving')).toBe(true) // Wait until the reply is actually flushed instead of sleeping a fixed // interval: the reply legitimately trails the paste's async validation plus diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 6185198d59e..f5861cbc810 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -523,7 +523,7 @@ describe('createRemoteRuntimePtyTransport', () => { } send.mockClear() - expect(transport.sendInput('ls\r')).toBe(true) + expect(transport.sendInput('ls\r', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() const inputFrame = decodeTerminalStreamFrame(send.mock.calls[0][0]) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts index 398efce980a..58034193be8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts @@ -249,7 +249,9 @@ describe('createRemoteRuntimePtyTransport', () => { expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) const listCallsAfterBound = hostListCalls - await expect(transport.sendInputAccepted?.('retry while reconnecting')).resolves.toBe(false) + await expect( + transport.sendInputAccepted?.('retry while reconnecting', 'driving') + ).resolves.toBe(false) await vi.advanceTimersByTimeAsync(16_000) // The accepted-snapshot listener already owns recovery. User input must diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts index 999716aa43d..b77f0b26e82 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts @@ -143,9 +143,9 @@ describe('createRemoteRuntimePtyTransport', () => { }) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) expect(transport.claimViewport?.(101, 33)).toBe(true) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) expect(transport.sendInputImmediate('\x1b[?1;2c')).toBe(true) - expect(transport.sendInput('z')).toBe(true) + expect(transport.sendInput('z', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) expect(runtimeCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) @@ -193,8 +193,8 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - expect(transport.sendInput('b')).toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + expect(transport.sendInput('b', 'driving')).toBe(true) expect(runtimeCall).not.toHaveBeenCalled() await vi.runOnlyPendingTimersAsync() @@ -225,8 +225,8 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - expect(transport.sendInput('b')).toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + expect(transport.sendInput('b', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -256,14 +256,14 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) - expect(transport.sendInput(chunk)).toBe(true) + expect(transport.sendInput(chunk, 'driving')).toBe(true) expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) let frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) expect(frame?.streamId).toBe(streamId) expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe(chunk) - expect(transport.sendInput('tail')).toBe(true) + expect(transport.sendInput('tail', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -299,7 +299,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.send', @@ -339,8 +339,8 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).toHaveBeenCalledWith({ @@ -383,7 +383,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(false) }) it('splits large acknowledged remote input before terminal.send RPCs', async () => { @@ -415,7 +415,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) const chunk = '😀'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES / 4) - await expect(transport.sendInputAccepted?.(`${chunk}tail`)).resolves.toBe(true) + await expect(transport.sendInputAccepted?.(`${chunk}tail`, 'driving')).resolves.toBe(true) const sendCalls = runtimeCall.mock.calls.filter((call) => call[0].method === 'terminal.send') expect(sendCalls).toHaveLength(2) @@ -455,7 +455,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) runtimeCall.mockClear() - const accepted = transport.sendInputAccepted?.(text) + const accepted = transport.sendInputAccepted?.(text, 'driving') await Promise.resolve() expect(runtimeCall).not.toHaveBeenCalled() @@ -502,9 +502,9 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.(`${firstChunk}${rejectedChunk}after`)).resolves.toBe( - false - ) + await expect( + transport.sendInputAccepted?.(`${firstChunk}${rejectedChunk}after`, 'driving') + ).resolves.toBe(false) const sendTexts = runtimeCall.mock.calls .filter((call) => call[0].method === 'terminal.send') @@ -530,7 +530,10 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() await expect( - transport.sendInputAccepted?.('😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1)) + transport.sendInputAccepted?.( + '😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1), + 'driving' + ) ).resolves.toBe(false) expect(runtimeCall).not.toHaveBeenCalled() }) @@ -550,7 +553,7 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('echo one\necho two\r\n')).toBe(true) + expect(transport.sendInput('echo one\necho two\r\n', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -614,7 +617,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() expect(transport.claimViewport?.(101, 33)).toBe(true) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() const frames = subscriptionSendBinary.mock.calls.map((call) => diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts index 6230bd00d8f..30c03745abe 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts @@ -60,7 +60,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(onWriteUnavailable).toHaveBeenCalledOnce()) @@ -103,7 +103,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable, onError } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(onWriteUnavailable).toHaveBeenCalledOnce()) @@ -148,7 +148,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable: oldWriteUnavailable } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('old')).toBe(true) + expect(transport.sendInput('old', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( @@ -205,7 +205,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: {} }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old')) - expect(transport.sendInput('queued-for-old')).toBe(true) + expect(transport.sendInput('queued-for-old', 'driving')).toBe(true) resolvedPaneHandle = 'terminal-new' transport.attach({ @@ -322,7 +322,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() // Why: replacement input stays disabled until terminal.resolvePane proves the new handle belongs to this pane. - expect(transport.sendInput('x')).toBe(false) + expect(transport.sendInput('x', 'driving')).toBe(false) vi.advanceTimersByTime(8) const inputFrames = subscriptionSendBinary.mock.calls diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts index 53c1921fe74..cec52958852 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts @@ -226,7 +226,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-rebind') + const pendingSend = sendInputAccepted('sent-before-rebind', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) ) @@ -293,7 +293,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-close') + const pendingSend = sendInputAccepted('sent-before-close', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) ) @@ -390,7 +390,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-stream-end') + const pendingSend = sendInputAccepted('sent-before-stream-end', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts index 5a61a5587e6..ce689695bc0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts @@ -76,7 +76,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-stream-end') + const pendingSend = sendInputAccepted('sent-before-stream-end', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) @@ -361,7 +361,10 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const sends = Promise.all([sendInputAccepted('first'), sendInputAccepted('second')]) + const sends = Promise.all([ + sendInputAccepted('first', 'driving'), + sendInputAccepted('second', 'driving') + ]) await vi.waitFor(() => expect(hostListCalls).toBe(1)) await expect(sends).resolves.toEqual([false, false]) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index e0b85414e3f..c3d61a8b3d3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -466,7 +466,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput(`detached-${cycle}`)).toBe(false) + expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(false) expect(unsubscribeByEpoch[cycle]).toHaveBeenCalledTimes(1) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(cycle + 2)) await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) @@ -542,7 +542,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(disconnectedState?.phase).toBe('disconnected') expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('must not reach a stale socket')).toBe(false) + expect(transport.sendInput('must not reach a stale socket', 'driving')).toBe(false) expect(onError).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(5 * 60_000) expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff) @@ -591,7 +591,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) expect(transport.claimViewport?.(101, 33)).toBe(true) - const accepted = transport.sendInputAccepted?.('\x03') + const accepted = transport.sendInputAccepted?.('\x03', 'driving') await Promise.resolve() rejectReconnect(new Error('reconnect failed')) @@ -610,7 +610,7 @@ describe('createRemoteRuntimePtyTransport', () => { const { streamId } = latestSubscribePayload() expect(transport.claimViewport?.(101, 33)).toBe(true) - const accepted = transport.sendInputAccepted?.('x') + const accepted = transport.sendInputAccepted?.('x', 'driving') subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'end', streamId } 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 17d78934e53..a8e8a727ad8 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 @@ -2583,6 +2583,8 @@ export function createRemoteRuntimePtyTransport( storedCallbacks = {} }, + // Why no kind: terminal.send has no launch kind, and its query-reply kind is for mobile + // clients, so the host classifies a desktop's bytes itself. sendInput(data: string): boolean { if (!connected || !handle || recoveryBlocksIo()) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts index df997a87afc..05513b53c66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts @@ -27,7 +27,7 @@ describe('sendCapturedTerminalInput', () => { data: '\r' }) ).toBe(true) - expect(transport.sendInput).toHaveBeenCalledWith('\r') + expect(transport.sendInput).toHaveBeenCalledWith('\r', 'driving') } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts index 7a2780a5d32..5409d5420de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts @@ -32,7 +32,7 @@ export function sendCapturedTerminalInput({ ) { return false } - const sent = capturedTransport.sendInput(data) + const sent = capturedTransport.sendInput(data, 'driving') if (sent) { onAccepted?.() } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index ee6a492f642..26e8f21d3fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -156,7 +156,8 @@ describe('handleTerminalFileDrop', () => { { assertCurrent: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png') + wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png'), + 'driving' ) expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -252,7 +253,8 @@ describe('handleTerminalFileDrop', () => { { assertCurrent: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png') + wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png'), + 'driving' ) }) @@ -308,7 +310,7 @@ describe('handleTerminalFileDrop', () => { '/remote/repo/.orca/drops', { assertCurrent: expect.any(Function) } ) - expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ', 'driving') }) it('keeps explicit local worktree drops local while a runtime is focused', async () => { @@ -335,7 +337,7 @@ describe('handleTerminalFileDrop', () => { }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() - expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(focus).toHaveBeenCalled() }) @@ -382,8 +384,12 @@ describe('handleTerminalFileDrop', () => { }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() - expect(sendInput).toHaveBeenNthCalledWith(1, "'/mnt/c/Users/alice/Desktop/notes one.txt' ") - expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ') + expect(sendInput).toHaveBeenNthCalledWith( + 1, + "'/mnt/c/Users/alice/Desktop/notes one.txt' ", + 'driving' + ) + expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ', 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -413,7 +419,7 @@ describe('handleTerminalFileDrop', () => { data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } }) - expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -452,7 +458,7 @@ describe('handleTerminalFileDrop', () => { expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() - expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(targetFocus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) @@ -508,8 +514,8 @@ describe('handleTerminalFileDrop', () => { worktreePath: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo' }) expect(sendInput.mock.calls).toEqual([ - ["'/mnt/c/Users/Name/My Project/file.txt' "], - ['/home/user/repo/README.md '] + ["'/mnt/c/Users/Name/My Project/file.txt' ", 'driving'], + ['/home/user/repo/README.md ', 'driving'] ]) expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -608,7 +614,7 @@ describe('handleTerminalFileDrop', () => { expectedSshTargetId: 'ssh-win', expectedSshConnectionGeneration: 4 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -684,6 +690,6 @@ describe('handleTerminalFileDrop', () => { data: { paths: ["/Users/me/it's here.txt"], target: 'terminal' } }) - expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ") + expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts index 2c1c2652e87..cd42929c248 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts @@ -116,7 +116,10 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 2 }) - expect(sendInput.mock.calls).toEqual([['/repo/a.ts '], ["'/repo/my file.ts' "]]) + expect(sendInput.mock.calls).toEqual([ + ['/repo/a.ts ', 'driving'], + ["'/repo/my file.ts' ", 'driving'] + ]) expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -155,7 +158,7 @@ describe('handleInternalTerminalFileDrop', () => { expect(result).toEqual({ status: 'cancelled', reason: 'target-stale', pathCount: 1 }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(replacementSendInput).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -187,7 +190,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() @@ -254,7 +257,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\repo\\a&b.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\repo\\a&b.txt" ', 'driving') }) it('uses SSH remote platform metadata for Windows internal file drops', async () => { @@ -293,7 +296,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -334,7 +337,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ") + expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -387,7 +390,7 @@ describe('handleInternalTerminalFileDrop', () => { expect(result).toEqual({ status: 'pasted', pathCount: 1 }) expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() - expect(targetSendInput).toHaveBeenCalledWith('/repo/drop-target.ts ') + expect(targetSendInput).toHaveBeenCalledWith('/repo/drop-target.ts ', 'driving') expect(targetFocus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts index 7fcb898d7eb..20fe46c1fda 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts @@ -48,7 +48,7 @@ describe('terminal drop path writer', () => { failureReason: 'write-rejected' }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() }) @@ -70,7 +70,8 @@ describe('terminal drop path writer', () => { // Why: image attachment detection in terminal TUIs keys off bracketed paste // of the literal path — no shell-escaping, no trailing space. expect(sendInputAccepted).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/repo/My Screenshot.png') + wrapTerminalBracketedPasteText('/repo/My Screenshot.png'), + 'driving' ) }) @@ -88,10 +89,11 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) - expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ', 'driving') expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - wrapTerminalBracketedPasteText('/repo/shot.png') + wrapTerminalBracketedPasteText('/repo/shot.png'), + 'driving' ) }) @@ -113,9 +115,10 @@ describe('terminal drop path writer', () => { // non-image path would collide with it without an explicit separator. expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + `${wrapTerminalBracketedPasteText('/repo/shot.png')} `, + 'driving' ) - expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ', 'driving') }) it('does not insert a separator between back-to-back image pastes', async () => { @@ -136,11 +139,13 @@ describe('terminal drop path writer', () => { // TUI input between the two attachments. expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - wrapTerminalBracketedPasteText('/repo/one.png') + wrapTerminalBracketedPasteText('/repo/one.png'), + 'driving' ) expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - wrapTerminalBracketedPasteText('/repo/two.png') + wrapTerminalBracketedPasteText('/repo/two.png'), + 'driving' ) }) @@ -158,7 +163,10 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) - expect(sendInputAccepted).toHaveBeenCalledWith("'/repo/a.png; touch /tmp/pwned #.png' ") + expect(sendInputAccepted).toHaveBeenCalledWith( + "'/repo/a.png; touch /tmp/pwned #.png' ", + 'driving' + ) }) it('falls back to shell escaping for image paths with Windows shell metacharacters', async () => { @@ -175,7 +183,7 @@ describe('terminal drop path writer', () => { targetShell: 'windows' }) - expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ') + expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ', 'driving') }) it('separates an image paste from a following image path that must be shell escaped', async () => { @@ -194,9 +202,14 @@ describe('terminal drop path writer', () => { expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + `${wrapTerminalBracketedPasteText('/repo/shot.png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + "'/repo/a.png; touch /tmp/pwned #.png' ", + 'driving' ) - expect(sendInputAccepted).toHaveBeenNthCalledWith(2, "'/repo/a.png; touch /tmp/pwned #.png' ") }) it('times out dropped path writes that never receive PTY acknowledgement', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index f8ed3766251..824a812ddb7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -72,7 +72,7 @@ export async function writeTerminalDropPathsToCapturedTarget({ ) : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( - () => writeTerminalPastePtyInput(liveTransport, payload), + () => writeTerminalPastePtyInput(liveTransport, payload, 'driving'), operationTimeoutMs ) if (writeResult.timedOut) { diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts index 7b7ed78989f..45fe1ee23ab 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts @@ -99,8 +99,8 @@ export function readTerminalImeCompositionSessionDetail( export function installTerminalImeCompositionRoute(args: { terminalElement: HTMLElement | null | undefined terminal: Pick - capturedTransport: PtyTransport - getCurrentTransport: () => PtyTransport | undefined + capturedTransport: Pick + getCurrentTransport: () => Pick | undefined }): IDisposable { const terminalElement = args.terminalElement const sessions = new Map() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts new file mode 100644 index 00000000000..5b0accad22c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom +// IME and Hangul commits bypass xterm's key path and enter through `terminal.input`. Main records +// a run's first user input from xterm's user-input signal, so every commit route must carry it. +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { IDisposable } from '@xterm/xterm' +import type { PtyTransport } from './pty-transport' +import { + installTerminalImeCompositionRoute, + XTERM_COMPOSITION_SESSION_END_EVENT, + XTERM_COMPOSITION_SESSION_START_EVENT +} from './terminal-ime-composition-route' +import { IPAD_DESKTOP_MODE_UA, pretendIosWeb } from './terminal-ios-hangul-preedit-fixture' +import { installTerminalPaneInputHandling } from './terminal-pane-pane-input' +import { subscribeToTerminalInputData } from './terminal-user-input-signal' + +const MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' + +type ForwardedInput = { data: string; wasUserInput: boolean } + +const disposables: IDisposable[] = [] + +function openPane(): { + terminal: Terminal + textarea: HTMLTextAreaElement + forwarded: ForwardedInput[] +} { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ cols: 40, rows: 8 }) + disposables.push(terminal) + terminal.open(container) + installTerminalPaneInputHandling({ + pane: { id: 1, terminal }, + managerRef: { current: null }, + paneKittyKeyboardModesRef: { current: new Map() }, + settingsRef: { current: {} }, + imeCompositionDisposablesRef: { current: new Map() }, + imeNativeTextForwarderDisposablesRef: { current: new Map() } + }) + const forwarded: ForwardedInput[] = [] + disposables.push( + subscribeToTerminalInputData(terminal, (data, wasUserInput) => + forwarded.push({ data, wasUserInput }) + ) + ) + return { terminal, textarea: terminal.textarea!, forwarded } +} + +function key( + textarea: HTMLTextAreaElement, + type: 'keydown' | 'keypress' | 'keyup', + init: { key: string; code: string; keyCode: number } +): KeyboardEvent { + const event = new KeyboardEvent(type, { ...init, bubbles: true, cancelable: true }) + // happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's key paths read them. + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + Object.defineProperty(event, 'charCode', { value: type === 'keypress' ? init.keyCode : 0 }) + textarea.dispatchEvent(event) + return event +} + +function insertText(textarea: HTMLTextAreaElement, inputType: string, data: string | null): void { + const event = new InputEvent('input', { bubbles: true }) + Object.defineProperty(event, 'inputType', { value: inputType }) + Object.defineProperty(event, 'data', { value: data }) + Object.defineProperty(event, 'composed', { value: true }) + textarea.dispatchEvent(event) +} + +describe('IME and Hangul commits reach the PTY as user input', () => { + let originalUserAgent: PropertyDescriptor | undefined + let originalMaxTouchPoints: PropertyDescriptor | undefined + let originalGetContext: PropertyDescriptor | undefined + + beforeEach(() => { + originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent') + originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints') + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext') + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: () => ({ measureText: () => ({ width: 10 }) }) + }) + }) + + afterEach(() => { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + for (const [property, descriptor] of [ + ['userAgent', originalUserAgent], + ['maxTouchPoints', originalMaxTouchPoints] + ] as const) { + // Why delete: with no own descriptor the real value lives on the prototype, which the stub shadows. + if (descriptor) { + Object.defineProperty(navigator, property, descriptor) + } else { + Reflect.deleteProperty(navigator, property) + } + } + if (originalGetContext) { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext) + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext') + } + document.body.replaceChildren() + }) + + it('marks a macOS input-source substitution as user input', () => { + pretendIosWeb(0, MAC_UA) + const { textarea, forwarded } = openPane() + + const keydown = key(textarea, 'keydown', { key: ',', code: 'Comma', keyCode: 188 }) + expect(keydown.defaultPrevented).toBe(false) + textarea.value = ',' + insertText(textarea, 'insertText', ',') + key(textarea, 'keyup', { key: ',', code: 'Comma', keyCode: 188 }) + + expect(forwarded).toEqual([{ data: ',', wasUserInput: true }]) + }) + + it('marks an iPadOS Hangul syllable commit as user input', async () => { + pretendIosWeb(5, IPAD_DESKTOP_MODE_UA) + const { textarea, forwarded } = openPane() + const typeJamo = async (jamo: string, written: string, replaces: boolean) => { + if ( + !key(textarea, 'keydown', { key: jamo, code: 'KeyQ', keyCode: jamo.charCodeAt(0) }) + .defaultPrevented + ) { + key(textarea, 'keypress', { key: jamo, code: 'KeyQ', keyCode: jamo.charCodeAt(0) }) + } + if (replaces) { + textarea.value = textarea.value.slice(0, -1) + insertText(textarea, 'deleteContentBackward', null) + } + textarea.value += written + insertText(textarea, 'insertText', written) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + } + + await typeJamo('ㅎ', 'ㅎ', false) + await typeJamo('ㅏ', '하', true) + await typeJamo('ㄴ', '한', true) + await typeJamo('ㄱ', 'ㄱ', false) + + expect(forwarded).toEqual([{ data: '한', wasUserInput: true }]) + }) + + it('marks a composition the route delivers after a pane switch as user input', () => { + const { terminal, forwarded } = openPane() + const transport: Pick = { getPtyId: () => 'pty-1' } + disposables.push( + installTerminalImeCompositionRoute({ + terminalElement: terminal.element!, + terminal, + capturedTransport: transport, + getCurrentTransport: () => transport + }) + ) + const session = (type: string, data?: string): CustomEvent => + new CustomEvent(type, { + bubbles: true, + cancelable: true, + detail: { id: 1, data, dataPendingReconciliation: false } + }) + + terminal.element!.dispatchEvent(session(XTERM_COMPOSITION_SESSION_START_EVENT)) + terminal.element!.dispatchEvent(session(XTERM_COMPOSITION_SESSION_END_EVENT, '한')) + + expect(forwarded).toEqual([{ data: '한', wasUserInput: true }]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts index 5f65a429922..42f032b17f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts @@ -210,7 +210,7 @@ describe('the iPadOS Hangul path alongside everything else', () => { pretendIosWeb() const rig = openIosTerminal() const sent: string[] = [] - writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ') + writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ', 'driving') expect(sent).toEqual(['echo ']) await typeHangeul(rig) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts index 295b7e35c53..b27900d2097 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts @@ -89,7 +89,7 @@ export const executeTerminalPaneMenuPasteText = async ( const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => isPanePasteTargetMounted(context, pane, transport, ptyId), canContinue: () => isPanePasteTargetMounted(context, pane, transport, ptyId) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts index b639d3b6ef8..6ecab6f332b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts @@ -33,7 +33,7 @@ import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll import { resetTerminalKeyboardProtocolAfterInterrupt } from './terminal-pane-lifecycle-primitives' type PaneInputContext = { - pane: ManagedPane + pane: Pick managerRef: React.RefObject paneKittyKeyboardModesRef: UseTerminalPaneLifecycleDeps['paneKittyKeyboardModesRef'] settingsRef: React.RefObject | null | undefined> diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts b/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts index e436bd2cc6a..309c42dc18d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts @@ -93,7 +93,7 @@ export function createTerminalPanePasteExecution( const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => { if (!isPanePasteTargetMounted(pane, transport, ptyId)) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts index b36f0d06f65..f08926b2c81 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -54,7 +54,13 @@ describe('terminal paste operation ordering', () => { pasteText: () => {}, writePty, yieldToEventLoop: async () => { - startupDraft ??= sendAgentDraftPasteContent(null, 'pty-1', 'GENERATED_CONTEXT', writePty) + startupDraft ??= sendAgentDraftPasteContent( + null, + 'pty-1', + 'GENERATED_CONTEXT', + 'launch', + writePty + ) } }) await startupDraft @@ -80,6 +86,7 @@ describe('terminal paste operation ordering', () => { null, 'pty-1', 'G'.repeat(64 * 1024 + 1), + 'launch', async (data) => { writes.push({ owner: 'startup', data }) if (data === BRACKETED_PASTE_START) { diff --git a/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts b/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts index f92427e4729..4f3549a15ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts @@ -66,7 +66,7 @@ export function handleTerminalProgrammaticTextPaste({ .then((plan) => executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => isTerminalPanePasteTargetCurrent({ manager: getManager(), diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts index e501c50402d..5a587c6acc9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts @@ -8,21 +8,21 @@ describe('terminal PTY paste writer', () => { const sendInputAccepted = vi.fn().mockResolvedValue(true) await expect( - writeTerminalPastePtyInput({ sendInput, sendInputAccepted }, 'payload') + writeTerminalPastePtyInput({ sendInput, sendInputAccepted }, 'payload', 'driving') ).resolves.toBe(true) - expect(sendInputAccepted).toHaveBeenCalledWith('payload') + expect(sendInputAccepted).toHaveBeenCalledWith('payload', 'driving') expect(sendInput).not.toHaveBeenCalled() }) it('falls back to queued PTY writes when acknowledged writes are unavailable', () => { const sendInput = vi.fn().mockReturnValue(true) - expect(writeTerminalPastePtyInput({ sendInput }, 'payload')).toBe(true) - expect(sendInput).toHaveBeenCalledWith('payload') + expect(writeTerminalPastePtyInput({ sendInput }, 'payload', 'driving')).toBe(true) + expect(sendInput).toHaveBeenCalledWith('payload', 'driving') }) it('rejects writes without a transport', () => { - expect(writeTerminalPastePtyInput(undefined, 'payload')).toBe(false) + expect(writeTerminalPastePtyInput(undefined, 'payload', 'driving')).toBe(false) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts index dd7e257615a..d1fffbc76fb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts @@ -1,15 +1,17 @@ import type { PtyTransport } from './pty-transport' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' type TerminalPastePtyWriter = Pick export function writeTerminalPastePtyInput( transport: TerminalPastePtyWriter | undefined, - data: string + data: string, + inputKind: TerminalInputKind ): boolean | Promise { if (!transport) { return false } // Why: paste chunking must respect PTY backpressure. sendInput only queues // local writes, while sendInputAccepted resolves after the PTY accepts them. - return transport.sendInputAccepted?.(data) ?? transport.sendInput(data) + return transport.sendInputAccepted?.(data, inputKind) ?? transport.sendInput(data, inputKind) } diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts index 7759e8802bf..11c0449bd96 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts @@ -40,7 +40,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('git status\r') + expect(sendInput).toHaveBeenCalledWith('git status\r', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -62,7 +62,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(false) - expect(sendInput).toHaveBeenCalledWith('npm test') + expect(sendInput).toHaveBeenCalledWith('npm test', 'driving') expect(pane.terminal.focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).not.toHaveBeenCalled() }) @@ -85,7 +85,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('cd packages; bun run build; cd ..\r') + expect(sendInput).toHaveBeenCalledWith('cd packages; bun run build; cd ..\r', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() }) @@ -107,7 +107,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('echo one; echo two') + expect(sendInput).toHaveBeenCalledWith('echo one; echo two', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts index 39d49d02c86..5c8d3622b00 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts @@ -5,6 +5,7 @@ import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import type { PtyTransport } from './pty-transport-types' type QuickCommandPane = { leafId: string @@ -13,9 +14,7 @@ type QuickCommandPane = { } } -type QuickCommandTransport = { - sendInput: (data: string) => boolean -} +type QuickCommandTransport = Pick export function sendTerminalQuickCommandToPane({ command, @@ -36,7 +35,8 @@ export function sendTerminalQuickCommandToPane({ } const sent = transport.sendInput( - buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)) + buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)), + 'driving' ) if (sent) { recordTerminalUserInputForLeaf(tabId, pane.leafId) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts index 3a2307cd87d..913c8b6cfdc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts @@ -47,7 +47,7 @@ export async function executeTerminalStartupCommandPaste({ return executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'launch'), isTargetCurrent: isCurrent, canContinue: isCurrent }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts index 5ce2bfc438e..009ab6b800a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts @@ -196,7 +196,7 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(clickedPane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: targetStillMounted, canContinue: targetStillMounted }) diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index a76086b6790..19d5a30d275 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -12,6 +12,7 @@ import { } from '@/components/terminal-pane/terminal-bracketed-paste' import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' // Why: bracketed paste markers let supported TUIs treat generated prompt text // as one paste instead of echoing character-by-character or triggering edits. @@ -29,10 +30,11 @@ export async function sendAgentDraftPasteContent( settings: Pick | null | undefined, ptyId: string, content: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { return await runTerminalPtyInputTransaction(ptyId, () => - sendAgentDraftPasteContentNow(settings, ptyId, content, writePty) + sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind, writePty) ) } @@ -42,6 +44,7 @@ export async function sendAgentDraftPasteContentNow( settings: Pick | null | undefined, ptyId: string, content: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { @@ -57,6 +60,7 @@ export async function sendAgentDraftPasteContentNow( settings, ptyId, wrapTerminalBracketedPasteText(terminalContent), + inputKind, writePty ) } @@ -71,16 +75,16 @@ export async function sendAgentDraftPasteContentNow( for (const chunk of iterateAgentDraftPasteContentChunks(terminalContent)) { let accepted = false try { - accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, writePty) + accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, inputKind, writePty) } catch { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, writePty) + await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) } return false } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, writePty) + await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) } return false } @@ -197,20 +201,24 @@ async function writeAgentDraftPtyInput( settings: Pick | null | undefined, ptyId: string, data: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { - return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(settings, ptyId, data) + return writePty + ? await writePty(data) + : await sendRuntimePtyInputVerified(settings, ptyId, data, inputKind) } async function closeAgentDraftBracketedPaste( settings: Pick | null | undefined, ptyId: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { try { // Why: once the opener reached the PTY, a failed content chunk should not // leave the target TUI in bracketed-paste mode. - await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, writePty) + await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, inputKind, writePty) } catch { // The original write already failed; callers only need the paste to fail closed. } diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index 207c70ada36..48534cf8b06 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -49,7 +49,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) it(`still refuses to type into a bare ${agent} shell foreground`, async () => { @@ -105,6 +105,6 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) }) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index bdcf0b4a92f..e520cb6df1f 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -22,7 +22,7 @@ export async function sendFollowupPromptWhenAgentReady(args: { return false } try { - return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`, 'launch') } catch { return false } diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 5b763aa5c6d..84ad14770d1 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -105,7 +105,12 @@ describe('post-paste submit retry Enter', () => { await expect(promise).resolves.toBe(true) expect(enterWrites()).toHaveLength(2) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + {}, + 'pty-1', + '\r', + 'launch' + ) expect(vi.getTimerCount()).toBe(0) }) @@ -143,7 +148,8 @@ describe('post-paste submit retry Enter', () => { const competing = sendAgentDraftPasteContent( {}, 'pty-1', - 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), + 'driving' ) await flushMicrotasks(10) expect(writes).toEqual([PASTED_ISSUE_URL, '\r']) diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index fdcb76cf2ed..bb07fb58504 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -57,7 +57,9 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, - sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + // Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds. + sendRuntimePtyInputVerified: (settings: unknown, ptyId: string, data: string) => + testState.sendRuntimePtyInputVerified(settings, ptyId, data), inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess })) @@ -726,7 +728,8 @@ describe('pasteDraftWhenAgentReady', () => { const competing = sendAgentDraftPasteContent( {}, 'pty-1', - 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), + 'driving' ) await flushMicrotasks(10) expect(writes).toEqual([PASTED_ISSUE_URL]) @@ -902,7 +905,7 @@ describe('pasteDraftWhenAgentReady', () => { it('yields during large accepted-size preflight before writing agent draft chunks', async () => { const content = 'x'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 300 * 1024) - const promise = sendAgentDraftPasteContent({}, 'pty-1', content) + const promise = sendAgentDraftPasteContent({}, 'pty-1', content, 'driving') await flushMicrotasks(5) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() @@ -915,9 +918,9 @@ describe('pasteDraftWhenAgentReady', () => { }) it('rejects oversized agent drafts before any PTY write', async () => { - await expect( - sendAgentDraftPasteContent({}, 'pty-1', 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1)) - ).resolves.toBe(false) + const oversized = 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1) + const result = sendAgentDraftPasteContent({}, 'pty-1', oversized, 'driving') + await expect(result).resolves.toBe(false) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index a260d6bee18..feca855338a 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TuiAgent } from '../../../shared/tui-agent' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { useAppStore } from '@/store' @@ -136,7 +137,9 @@ export async function pasteDraftWhenAgentReady(args: { ptyId, content, submit: submit === true, - agent + agent, + // Why launch: this delivers the prompt or draft an agent is started with. + inputKind: 'launch' }) } @@ -188,7 +191,9 @@ export async function pasteDraftToAgentPtyWhenReady(args: { ptyId, content, submit: submit === true, - agent + agent, + // Why launch: this delivers the prompt or draft an agent is started with. + inputKind: 'launch' }) } @@ -201,7 +206,8 @@ export async function submitPromptToAgentPty(args: { settings: getSettingsForAgentTabRuntimeOwner(args.tabId), ptyId: args.ptyId, content: args.content, - submit: true + submit: true, + inputKind: 'driving' }) } @@ -209,7 +215,7 @@ export async function sendBracketedPasteToRunningAgent(args: { ptyId: string content: string }): Promise { - return await sendBracketedPasteToAgent({ ptyId: args.ptyId, content: args.content, submit: true }) + return await sendBracketedPasteToAgent({ ...args, submit: true, inputKind: 'driving' }) } async function sendBracketedPasteToAgent(args: { @@ -218,14 +224,16 @@ async function sendBracketedPasteToAgent(args: { content: string submit: boolean agent?: TuiAgent + inputKind: TerminalInputKind }): Promise { const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const { inputKind } = args const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { - const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) + const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind) if (!pasted || !submit) { return pasted } @@ -234,14 +242,14 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r') + const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) if (submitRetryDelayMs !== undefined) { // Why: agents that render their composer before Enter is live silently eat // the first Enter; the retry is best-effort and never downgrades `submitted`. await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) try { - await sendRuntimePtyInputVerified(settings, ptyId, '\r') + await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) } catch { // Why: a rejected retry leaves the first Enter's verdict untouched. } diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 11ade27d58a..ed62c67f356 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -178,7 +178,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r" + "claude '--dangerously-skip-permissions' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -214,7 +215,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -241,7 +243,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r" + "codex '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -262,7 +265,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r" + "codex '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -288,7 +292,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -312,7 +317,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -353,7 +359,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 9ef4bcfae4a..07c39dd082a 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -118,7 +118,7 @@ export async function launchAgentBackgroundSession( command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: Boolean(sshConnectionId) && sshBackgroundLaunchWaitsForShellReady(startupPlan), - write: (ptyId, data) => window.api.pty.write(ptyId, data) + write: (ptyId, data) => window.api.pty.write(ptyId, data, 'launch') }) // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 78d59f781f2..d3ca4fa01e6 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -293,7 +293,12 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith({}, 'pty-1', 'fix the spinner\r') + expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + 'fix the spinner\r', + 'launch' + ) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) diff --git a/src/renderer/src/lib/startup-draft-input-kind.test.ts b/src/renderer/src/lib/startup-draft-input-kind.test.ts new file mode 100644 index 00000000000..34c96e3073f --- /dev/null +++ b/src/renderer/src/lib/startup-draft-input-kind.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +// A created worktree's draft reaches its agent by one of two renderer routes: the pane's own +// startup paste, or the draft helper that waits for the agent. Both must write it as launch input. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' +import { bindSettlePaneSerializer } from '@/components/terminal-pane/pty-connection/pane-serializer-settle' +import type { ConnectPanePtySession } from '@/components/terminal-pane/pty-connection/connect-pane-pty-session' +import { STARTUP_DRAFT_PASTE_QUIET_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' +import { pasteDraftToAgentPtyWhenReady, submitPromptToAgentPty } from './agent-paste-draft' + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ settings: {}, terminalLayoutsByTabId: {} }), + subscribe: () => () => {} + } +})) + +vi.mock('./worktree-runtime-owner', () => ({ + getSettingsForWorktreeRuntimeOwner: () => ({}), + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('./agent-draft-readiness', () => ({ + waitForAgentDraftInputReady: async () => true +})) + +const DRAFT = 'plan the migration' +const originalApi = window.api + +function stubPtyApi(): { kinds: TerminalInputKind[] } { + const kinds: TerminalInputKind[] = [] + const writeAccepted = vi.fn(async (_id: string, _data: string, inputKind: TerminalInputKind) => { + kinds.push(inputKind) + return true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both routes reach only pty.writeAccepted. + window.api = { pty: { writeAccepted, write: vi.fn() } } as unknown as typeof window.api + return { kinds } +} + +/** The pane route: the pane's transport pastes the draft once the agent's composer is ready. */ +async function paneRouteKinds(): Promise { + const { kinds } = stubPtyApi() + const transport = { + getPtyId: () => 'pty-1', + sendInput: () => true, + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => + window.api.pty.writeAccepted('pty-1', data, inputKind) + } + const pane = { id: 1 } + const bag: Record = { + pane, + transport, + disposed: false, + connectionId: null, + ownsStartupDraftPaste: true, + shouldDeliverStartupViaTerminalPaste: false, + startupDraftPrompt: DRAFT, + recordTerminalInputForHibernation: () => {}, + deps: { worktreeId: 'wt-1', paneTransportsRef: { current: new Map([[pane.id, transport]]) } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session is an any-typed bag; the draft path reads only the fields above. + const session = bag as unknown as ConnectPanePtySession + bindSettlePaneSerializer(session) + session.observeStartupDraftPasteReadiness('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(STARTUP_DRAFT_PASTE_QUIET_MS) + await vi.runAllTimersAsync() + return kinds +} + +/** The helper route: the draft helper waits for the agent, then writes through the runtime. */ +async function helperRouteKinds(): Promise { + const { kinds } = stubPtyApi() + const pasted = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: DRAFT, + agent: 'aider' + }) + await vi.runAllTimersAsync() + await expect(pasted).resolves.toBe(true) + return kinds +} + +describe('a created worktree’s startup draft', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + window.api = originalApi + }) + + it('writes as launch input on both renderer routes', async () => { + const paneKinds = await paneRouteKinds() + const helperKinds = await helperRouteKinds() + + expect(paneKinds.length).toBeGreaterThan(0) + expect(new Set(paneKinds)).toEqual(new Set(['launch'])) + expect(new Set(helperKinds)).toEqual(new Set(paneKinds)) + }) + + it('differs from a prompt submitted to an agent that is already running', async () => { + const { kinds } = stubPtyApi() + + const submitted = submitPromptToAgentPty({ tabId: 'tab-1', ptyId: 'pty-1', content: DRAFT }) + await vi.runAllTimersAsync() + + await expect(submitted).resolves.toBe(true) + expect(new Set(kinds)).toEqual(new Set(['driving'])) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index 7f54f5873bc..ded589e26b1 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -110,7 +110,12 @@ describe('runtime terminal owner routing', () => { it('sends input through the PTY owning environment instead of the active one', async () => { expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -341,7 +346,12 @@ describe('runtime terminal owner routing', () => { }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -372,7 +382,12 @@ describe('runtime terminal owner routing', () => { useAppStore.setState({ lastTerminalInputAtByPaneKey: {} }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) const nextLeafId = '22222222-2222-4222-8222-222222222222' useAppStore.setState({ @@ -417,7 +432,12 @@ describe('runtime terminal owner routing', () => { }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -436,7 +456,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-stale', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) }) @@ -452,7 +473,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) @@ -472,17 +494,19 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(true) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x') + sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') ).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x') + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') expect(localWrite).not.toHaveBeenCalled() }) it('rejects oversized fire-and-forget local input before IPC writes', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe(false) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(false) expect(localWrite).not.toHaveBeenCalled() expect(localWriteAccepted).not.toHaveBeenCalled() @@ -492,7 +516,12 @@ describe('runtime terminal owner routing', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', text) + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + text, + 'driving' + ) ).toBe(false) expect(runtimeTransportCall).not.toHaveBeenCalled() @@ -504,14 +533,14 @@ describe('runtime terminal owner routing', () => { try { const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe( - true - ) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(true) expect(localWrite).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(0) - expect(localWrite).toHaveBeenCalledWith('local-pty', text) + expect(localWrite).toHaveBeenCalledWith('local-pty', text, 'driving') expect(localWriteAccepted).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -523,9 +552,9 @@ describe('runtime terminal owner routing', () => { try { const text = makeByteOversizedTerminalInput() - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe( - true - ) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(true) await vi.runAllTimersAsync() @@ -540,7 +569,12 @@ describe('runtime terminal owner routing', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', text) + sendRuntimePtyInputVerified( + { activeRuntimeEnvironmentId: null }, + 'local-pty', + text, + 'driving' + ) ).resolves.toBe(false) expect(localWriteAccepted).not.toHaveBeenCalled() @@ -555,7 +589,8 @@ describe('runtime terminal owner routing', () => { const accepted = sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: null }, 'local-pty', - text + text, + 'driving' ) expect(localWriteAccepted).not.toHaveBeenCalled() @@ -563,7 +598,7 @@ describe('runtime terminal owner routing', () => { await vi.advanceTimersByTimeAsync(0) await expect(accepted).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', text) + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', text, 'driving') expect(localWrite).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -577,7 +612,8 @@ describe('runtime terminal owner routing', () => { const accepted = sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: null }, 'local-pty', - text + text, + 'driving' ) await vi.runAllTimersAsync() @@ -612,7 +648,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(true) @@ -643,7 +680,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) @@ -829,10 +867,10 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(false) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x') + sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') ).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x') - expect(localWrite).toHaveBeenCalledWith('local-pty', 'x') + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') + expect(localWrite).toHaveBeenCalledWith('local-pty', 'x', 'driving') }) }) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index b34d75f4555..e235bb5adef 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -20,6 +20,7 @@ export type { ClientOnlyUnverifiableInspection, ClientOnlyUnverifiableReason } from '../../../shared/terminal-process-inspection' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' export type RuntimeTerminalProcessInspection = TerminalProcessInspection @@ -216,7 +217,8 @@ export async function confirmRuntimeTerminalForegroundProcess( export function sendRuntimePtyInput( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): boolean { const tooLarge = isRuntimePtyInputTooLarge(data) if (tooLarge === true) { @@ -228,19 +230,22 @@ export function sendRuntimePtyInput( void tooLarge .then((resolvedTooLarge) => { if (!resolvedTooLarge) { - sendRuntimePtyInputWithinLimit(settings, ptyId, data) + sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) } }) .catch(() => {}) return true } - return sendRuntimePtyInputWithinLimit(settings, ptyId, data) + return sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) } +// Why the kind reaches only the local write: terminal.send has no launch kind, and its query-reply +// kind is for mobile clients, so the host classifies a desktop's environment write by its bytes. function sendRuntimePtyInputWithinLimit( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): boolean { const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) const target = ownerEnvironmentId @@ -248,7 +253,7 @@ function sendRuntimePtyInputWithinLimit( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - window.api.pty.write(ptyId, data) + window.api.pty.write(ptyId, data, inputKind) recordRuntimeTerminalInputForPtyId(ptyId) return true } @@ -274,7 +279,8 @@ function sendRuntimePtyInputWithinLimit( export async function sendRuntimePtyInputVerified( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): Promise { const tooLarge = isRuntimePtyInputTooLarge(data) if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { @@ -286,9 +292,9 @@ export async function sendRuntimePtyInputVerified( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - const accepted = await window.api.pty.writeAccepted(ptyId, data) + const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind) if (!accepted) { - window.api.pty.write(ptyId, data) + window.api.pty.write(ptyId, data, inputKind) // Why: SSH/local fallback writes are fire-and-forget. Callers use this // boolean to continue UX flow, while hook telemetry confirms real turns. recordRuntimeTerminalInputForPtyId(ptyId) diff --git a/src/shared/source-scan/call-argument-scan.ts b/src/shared/source-scan/call-argument-scan.ts new file mode 100644 index 00000000000..59a411aa2f0 --- /dev/null +++ b/src/shared/source-scan/call-argument-scan.ts @@ -0,0 +1,83 @@ +import { stripComments } from './source-tree-scan' + +const CLOSER_BY_OPENER: Record = { '(': ')', '[': ']', '{': '}' } + +/** Top-level argument texts of the call whose `(` ends at `start`; quote-aware, not a parser. */ +export function splitCallArguments(source: string, start: number): string[] { + const args: string[] = [] + const closers: string[] = [')'] + let current = '' + let quote: string | null = null + for (let index = start; index < source.length; index += 1) { + const char = source[index]! + if (quote) { + current += char + if (char === '\\') { + current += source[index + 1] ?? '' + index += 1 + } else if (char === quote) { + quote = null + } + continue + } + const closer = CLOSER_BY_OPENER[char] + if (char === "'" || char === '"' || char === '`') { + quote = char + } else if (closer) { + closers.push(closer) + } else if (char === closers.at(-1)) { + closers.pop() + if (closers.length === 0) { + break + } + } else if (char === ',' && closers.length === 1) { + args.push(current.trim()) + current = '' + continue + } + current += char + } + if (current.trim()) { + args.push(current.trim()) + } + return args +} + +export type RequiredCallArgument = { + /** Which positional argument must be present. */ + index: number + /** The call counts only when the text before `.method(` matches, e.g. its receiver. */ + receiver?: RegExp + /** When the argument is an object literal, whether it carries the required field. */ + acceptsObjectLiteral?: (literal: string) => boolean +} + +/** + * `line: .method(args)` for each call that leaves out a required argument. For code the compiler + * cannot check, such as `@ts-nocheck` files and `any`-typed receivers. + */ +export function findCallsMissingArgument( + source: string, + requiredByMethod: Record +): string[] { + const code = stripComments(source) + const callRe = new RegExp(`\\.\\s*(${Object.keys(requiredByMethod).join('|')})\\s*\\(`, 'g') + const missing: string[] = [] + for (const match of code.matchAll(callRe)) { + const method = match[1]! + const required = requiredByMethod[method]! + const before = code.slice(0, match.index) + if (required.receiver && !required.receiver.test(before)) { + continue + } + const args = splitCallArguments(code, match.index + match[0].length) + const argument = args[required.index] + const present = + argument !== undefined && + (!argument.startsWith('{') || (required.acceptsObjectLiteral?.(argument) ?? true)) + if (!present) { + missing.push(`${before.split('\n').length}: .${method}(${args.join(', ')})`) + } + } + return missing +} diff --git a/src/shared/terminal-input-kind.ts b/src/shared/terminal-input-kind.ts new file mode 100644 index 00000000000..f9cef0e2a18 --- /dev/null +++ b/src/shared/terminal-input-kind.ts @@ -0,0 +1,8 @@ +/** + * Why bytes are written to a PTY. Every host write entry point takes one, so no producer inherits + * a transport's default. + * - `driving`: input that drives the run, from a person or any client, driver or agent acting on it. + * - `launch`: bytes Orca writes to start the run: startup commands, a create-time draft or prompt. + * - `query-reply`: the terminal's automatic answer to a query the running program made. + */ +export type TerminalInputKind = 'driving' | 'launch' | 'query-reply'